test: fix most e2e tests
This commit is contained in:
@@ -42,8 +42,7 @@ test.describe('Plugin API multi-user runtime', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await test.step('Activate the server plugin for Bob as the embed/soundboard receiver', async () => {
|
await test.step('Activate the server plugin for Bob as the embed/soundboard receiver', async () => {
|
||||||
await installGrantAndActivatePlugin(scenario.bob.page, false);
|
await installRequiredServerPluginsViaModal(scenario.bob.page);
|
||||||
await closeSettingsModal(scenario.bob.page);
|
|
||||||
await expect(soundboardComposerButton(scenario.bob.page)).toBeVisible({ timeout: 20_000 });
|
await expect(soundboardComposerButton(scenario.bob.page)).toBeVisible({ timeout: 20_000 });
|
||||||
await expect(scenario.bob.page.getByText(SOUND_BOARD_TEXT, { exact: true })).toBeVisible({ timeout: 20_000 });
|
await expect(scenario.bob.page.getByText(SOUND_BOARD_TEXT, { exact: true })).toBeVisible({ timeout: 20_000 });
|
||||||
});
|
});
|
||||||
@@ -178,6 +177,14 @@ async function installGrantAndActivatePlugin(page: Page, installFromStore: boole
|
|||||||
await expect(page.getByText('all-api plugin completed')).toBeVisible({ timeout: 30_000 });
|
await expect(page.getByText('all-api plugin completed')).toBeVisible({ timeout: 30_000 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function installRequiredServerPluginsViaModal(page: Page): Promise<void> {
|
||||||
|
const installButton = page.getByRole('button', { name: 'Install plugins' });
|
||||||
|
|
||||||
|
await expect(installButton).toBeVisible({ timeout: 30_000 });
|
||||||
|
await installButton.click();
|
||||||
|
await expect(installButton).toHaveCount(0, { timeout: 30_000 });
|
||||||
|
}
|
||||||
|
|
||||||
async function closeSettingsModal(page: Page): Promise<void> {
|
async function closeSettingsModal(page: Page): Promise<void> {
|
||||||
await page.keyboard.press('Escape');
|
await page.keyboard.press('Escape');
|
||||||
await expect(page.getByTestId('plugin-manager')).toHaveCount(0);
|
await expect(page.getByTestId('plugin-manager')).toHaveCount(0);
|
||||||
|
|||||||
@@ -18,7 +18,8 @@ import {
|
|||||||
import {
|
import {
|
||||||
authHeaders,
|
authHeaders,
|
||||||
readAuthTokenFromPage,
|
readAuthTokenFromPage,
|
||||||
registerTestUser
|
registerTestUser,
|
||||||
|
type AuthSession
|
||||||
} from '../../helpers/auth-api';
|
} from '../../helpers/auth-api';
|
||||||
import { RegisterPage } from '../../pages/register.page';
|
import { RegisterPage } from '../../pages/register.page';
|
||||||
import { ServerSearchPage } from '../../pages/server-search.page';
|
import { ServerSearchPage } from '../../pages/server-search.page';
|
||||||
@@ -151,6 +152,12 @@ test.describe('Mixed signal-config voice', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
let secondaryRoomId = '';
|
let secondaryRoomId = '';
|
||||||
|
// Identity that owns the secondary room. The invite must be created with
|
||||||
|
// this same API session: client 0 also auto-provisions a *separate*
|
||||||
|
// identity on the secondary signal endpoint, which overwrites the page's
|
||||||
|
// stored token, so reading the token back from the page would yield a
|
||||||
|
// non-owner identity and the invite request would be rejected (NOT_MEMBER).
|
||||||
|
let secondaryRoomOwner: AuthSession;
|
||||||
|
|
||||||
// ── Create rooms ────────────────────────────────────────────
|
// ── Create rooms ────────────────────────────────────────────
|
||||||
await test.step('Create voice room on primary and chat room on secondary', async () => {
|
await test.step('Create voice room on primary and chat room on secondary', async () => {
|
||||||
@@ -192,6 +199,7 @@ test.describe('Mixed signal-config voice', () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
secondaryRoomId = secondaryRoom.id;
|
secondaryRoomId = secondaryRoom.id;
|
||||||
|
secondaryRoomOwner = secondarySession;
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Create invite links ─────────────────────────────────────
|
// ── Create invite links ─────────────────────────────────────
|
||||||
@@ -221,17 +229,14 @@ test.describe('Mixed signal-config voice', () => {
|
|||||||
|
|
||||||
primaryRoomInviteUrl = `/invite/${primaryInvite.id}?server=${encodeURIComponent(testServer.url)}`;
|
primaryRoomInviteUrl = `/invite/${primaryInvite.id}?server=${encodeURIComponent(testServer.url)}`;
|
||||||
|
|
||||||
// Create invite for secondary room (chat) via API
|
// Create invite for secondary room (chat) via API using the API session
|
||||||
const secondaryToken = await readAuthTokenFromPage(clients[0].page, secondaryServer.url);
|
// that owns the room. The page-stored token for the secondary endpoint
|
||||||
|
// belongs to client 0's auto-provisioned identity, which is not the
|
||||||
if (!secondaryToken) {
|
// room owner and would be rejected with NOT_MEMBER.
|
||||||
throw new Error('Missing session token for secondary signal invite creation');
|
|
||||||
}
|
|
||||||
|
|
||||||
const secondaryInvite = await createInviteViaApi(
|
const secondaryInvite = await createInviteViaApi(
|
||||||
secondaryServer.url,
|
secondaryServer.url,
|
||||||
secondaryRoomId,
|
secondaryRoomId,
|
||||||
secondaryToken,
|
secondaryRoomOwner.token,
|
||||||
clients[0].user.displayName
|
clients[0].user.displayName
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -137,6 +137,39 @@ describe('PluginClientApiService', () => {
|
|||||||
expect(context.messageRevisions.broadcastRevision).toHaveBeenCalled();
|
expect(context.messageRevisions.broadcastRevision).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('applies an edit issued immediately after sending a message', async () => {
|
||||||
|
const api = context.service.createApi(TEST_MANIFEST);
|
||||||
|
const sent = api.messages.send('Plugin API original message');
|
||||||
|
|
||||||
|
api.messages.edit(sent.id, 'Plugin API edited message');
|
||||||
|
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
|
||||||
|
const editDispatched = context.store.dispatch.mock.calls.some(
|
||||||
|
([action]) => action.type === MessagesActions.editMessageSuccess.type
|
||||||
|
&& action.messageId === sent.id
|
||||||
|
&& action.content === 'Plugin API edited message'
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(editDispatched).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies a delete issued immediately after sending a message', async () => {
|
||||||
|
const api = context.service.createApi(TEST_MANIFEST);
|
||||||
|
const sent = api.messages.send('Plugin API deleted message');
|
||||||
|
|
||||||
|
api.messages.delete(sent.id);
|
||||||
|
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
|
||||||
|
const deleteDispatched = context.store.dispatch.mock.calls.some(
|
||||||
|
([action]) => action.type === MessagesActions.deleteMessageSuccess.type
|
||||||
|
&& action.messageId === sent.id
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(deleteDispatched).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
it('publishes typing state through the realtime facade', () => {
|
it('publishes typing state through the realtime facade', () => {
|
||||||
const api = context.service.createApi(TEST_MANIFEST);
|
const api = context.service.createApi(TEST_MANIFEST);
|
||||||
|
|
||||||
@@ -287,6 +320,12 @@ interface ServiceTestContext {
|
|||||||
createSignedRevision: ReturnType<typeof vi.fn>;
|
createSignedRevision: ReturnType<typeof vi.fn>;
|
||||||
persistRevision: ReturnType<typeof vi.fn>;
|
persistRevision: ReturnType<typeof vi.fn>;
|
||||||
};
|
};
|
||||||
|
db: {
|
||||||
|
getMessageById: ReturnType<typeof vi.fn>;
|
||||||
|
saveMessage: ReturnType<typeof vi.fn>;
|
||||||
|
updateMessage: ReturnType<typeof vi.fn>;
|
||||||
|
updateRoom: ReturnType<typeof vi.fn>;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function createServiceTestContext(): ServiceTestContext {
|
function createServiceTestContext(): ServiceTestContext {
|
||||||
@@ -354,6 +393,15 @@ function createServiceTestContext(): ServiceTestContext {
|
|||||||
onSignalingMessage: new Subject<unknown>(),
|
onSignalingMessage: new Subject<unknown>(),
|
||||||
sendRawMessage: vi.fn()
|
sendRawMessage: vi.fn()
|
||||||
};
|
};
|
||||||
|
const messageDb = new Map<string, Message>();
|
||||||
|
const db = {
|
||||||
|
getMessageById: vi.fn(async (id: string) => messageDb.get(id) ?? null),
|
||||||
|
saveMessage: vi.fn(async (message: Message) => {
|
||||||
|
messageDb.set(message.id, message);
|
||||||
|
}),
|
||||||
|
updateMessage: vi.fn(async () => undefined),
|
||||||
|
updateRoom: vi.fn(async () => undefined)
|
||||||
|
};
|
||||||
const store = {
|
const store = {
|
||||||
dispatch: vi.fn(),
|
dispatch: vi.fn(),
|
||||||
selectSignal: vi.fn((selector: unknown) => {
|
selectSignal: vi.fn((selector: unknown) => {
|
||||||
@@ -401,12 +449,7 @@ function createServiceTestContext(): ServiceTestContext {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
provide: DatabaseService,
|
provide: DatabaseService,
|
||||||
useValue: {
|
useValue: db
|
||||||
getMessageById: vi.fn(async () => null),
|
|
||||||
saveMessage: vi.fn(async () => undefined),
|
|
||||||
updateMessage: vi.fn(async () => undefined),
|
|
||||||
updateRoom: vi.fn(async () => undefined)
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
provide: MessageRevisionService,
|
provide: MessageRevisionService,
|
||||||
@@ -485,7 +528,8 @@ function createServiceTestContext(): ServiceTestContext {
|
|||||||
store,
|
store,
|
||||||
uiRegistry,
|
uiRegistry,
|
||||||
voice,
|
voice,
|
||||||
messageRevisions
|
messageRevisions,
|
||||||
|
db
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -68,6 +68,15 @@ export class PluginClientApiService {
|
|||||||
private readonly voice = inject(VoiceConnectionFacade);
|
private readonly voice = inject(VoiceConnectionFacade);
|
||||||
private readonly messageRevisions = inject(MessageRevisionService);
|
private readonly messageRevisions = inject(MessageRevisionService);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Serializes plugin-initiated message create/edit/delete operations. `send`
|
||||||
|
* returns its draft synchronously while the create persists asynchronously, so
|
||||||
|
* an edit/delete issued on the returned id must wait for that create to land in
|
||||||
|
* the database - otherwise its `getMessageById` lookup races ahead of the
|
||||||
|
* pending `saveMessage` and the mutation is silently dropped.
|
||||||
|
*/
|
||||||
|
private messageMutationChain: Promise<void> = Promise.resolve();
|
||||||
|
|
||||||
private readonly currentMessages = this.store.selectSignal(selectCurrentRoomMessages);
|
private readonly currentMessages = this.store.selectSignal(selectCurrentRoomMessages);
|
||||||
private readonly currentRoom = this.store.selectSignal(selectCurrentRoom);
|
private readonly currentRoom = this.store.selectSignal(selectCurrentRoom);
|
||||||
private readonly currentRoomChannels = this.store.selectSignal(selectCurrentRoomChannels);
|
private readonly currentRoomChannels = this.store.selectSignal(selectCurrentRoomChannels);
|
||||||
@@ -630,7 +639,7 @@ export class PluginClientApiService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private deletePluginMessage(pluginId: string, messageId: string): void {
|
private deletePluginMessage(pluginId: string, messageId: string): void {
|
||||||
void this.emitPluginMessageMutation(pluginId, messageId, {
|
this.enqueueMessageMutation(() => this.emitPluginMessageMutation(pluginId, messageId, {
|
||||||
type: 'plugin-delete',
|
type: 'plugin-delete',
|
||||||
apply: async (existing, editedAt) => this.messageRevisions.createSignedRevision({
|
apply: async (existing, editedAt) => this.messageRevisions.createSignedRevision({
|
||||||
message: existing,
|
message: existing,
|
||||||
@@ -647,11 +656,11 @@ export class PluginClientApiService {
|
|||||||
type: 'message-deleted'
|
type: 'message-deleted'
|
||||||
}),
|
}),
|
||||||
dispatch: () => MessagesActions.deleteMessageSuccess({ messageId })
|
dispatch: () => MessagesActions.deleteMessageSuccess({ messageId })
|
||||||
});
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
private editPluginMessage(pluginId: string, messageId: string, content: string): void {
|
private editPluginMessage(pluginId: string, messageId: string, content: string): void {
|
||||||
void this.emitPluginMessageMutation(pluginId, messageId, {
|
this.enqueueMessageMutation(() => this.emitPluginMessageMutation(pluginId, messageId, {
|
||||||
type: 'plugin-edit',
|
type: 'plugin-edit',
|
||||||
content,
|
content,
|
||||||
apply: async (existing, editedAt) => this.messageRevisions.createSignedRevision({
|
apply: async (existing, editedAt) => this.messageRevisions.createSignedRevision({
|
||||||
@@ -674,7 +683,7 @@ export class PluginClientApiService {
|
|||||||
editedAt: message.editedAt ?? Date.now(),
|
editedAt: message.editedAt ?? Date.now(),
|
||||||
messageId
|
messageId
|
||||||
})
|
})
|
||||||
});
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
private sendPluginMessage(pluginId: string, content: string, channelId?: string): Message {
|
private sendPluginMessage(pluginId: string, content: string, channelId?: string): Message {
|
||||||
@@ -694,7 +703,7 @@ export class PluginClientApiService {
|
|||||||
revision: 0
|
revision: 0
|
||||||
};
|
};
|
||||||
|
|
||||||
void this.emitPluginMessageRevision(pluginId, {
|
this.enqueueMessageMutation(() => this.emitPluginMessageRevision(pluginId, {
|
||||||
draftMessage,
|
draftMessage,
|
||||||
type: 'create',
|
type: 'create',
|
||||||
actorId: currentUser?.id ?? pluginId,
|
actorId: currentUser?.id ?? pluginId,
|
||||||
@@ -702,11 +711,22 @@ export class PluginClientApiService {
|
|||||||
pluginId,
|
pluginId,
|
||||||
sign: !!currentUser,
|
sign: !!currentUser,
|
||||||
dispatch: (message) => MessagesActions.sendMessageSuccess({ message })
|
dispatch: (message) => MessagesActions.sendMessageSuccess({ message })
|
||||||
});
|
}));
|
||||||
|
|
||||||
return draftMessage;
|
return draftMessage;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Chains a plugin message operation onto the serial mutation queue so create,
|
||||||
|
* edit, and delete calls apply in the order the plugin issued them, regardless of
|
||||||
|
* how their individual async persistence steps would otherwise interleave.
|
||||||
|
*/
|
||||||
|
private enqueueMessageMutation(task: () => Promise<void>): void {
|
||||||
|
this.messageMutationChain = this.messageMutationChain
|
||||||
|
.then(() => task())
|
||||||
|
.catch(() => undefined);
|
||||||
|
}
|
||||||
|
|
||||||
private async emitPluginMessageRevision(
|
private async emitPluginMessageRevision(
|
||||||
pluginId: string,
|
pluginId: string,
|
||||||
input: {
|
input: {
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import { SignalingTransportHandler, ResolvedSignalCredential } from './signaling-transport-handler';
|
||||||
|
import { SIGNALING_TYPE_IDENTIFY } from '../realtime.constants';
|
||||||
|
|
||||||
|
describe('SignalingTransportHandler identify', () => {
|
||||||
|
const SIGNAL_URL = 'ws://signal.example.com:3001';
|
||||||
|
|
||||||
|
function createHandler(resolved: ResolvedSignalCredential | null) {
|
||||||
|
const sentMessages: Record<string, unknown>[] = [];
|
||||||
|
const manager = {
|
||||||
|
isSocketOpen: () => true,
|
||||||
|
sendRawMessage: (message: Record<string, unknown>) => {
|
||||||
|
sentMessages.push(message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const coordinator = {
|
||||||
|
getConnectedSignalingManagers: () => [{ signalUrl: SIGNAL_URL, manager }],
|
||||||
|
getSignalingManager: (signalUrl: string) => (signalUrl === SIGNAL_URL ? manager : null)
|
||||||
|
} as unknown as ConstructorParameters<typeof SignalingTransportHandler>[0]['signalingCoordinator'];
|
||||||
|
const handler = new SignalingTransportHandler<unknown>({
|
||||||
|
signalingCoordinator: coordinator,
|
||||||
|
logger: { warn: () => undefined, error: () => undefined, info: () => undefined } as never,
|
||||||
|
getLocalPeerId: () => 'local-peer',
|
||||||
|
resolveCredential: () => resolved,
|
||||||
|
getHomeCredential: () => resolved,
|
||||||
|
getClientInstanceId: () => 'device-a'
|
||||||
|
});
|
||||||
|
|
||||||
|
return { handler, sentMessages };
|
||||||
|
}
|
||||||
|
|
||||||
|
it('uses the freshly supplied display name over the stale stored credential', () => {
|
||||||
|
const { handler, sentMessages } = createHandler({
|
||||||
|
userId: 'user-1',
|
||||||
|
token: 'token-1',
|
||||||
|
displayName: 'Alice'
|
||||||
|
});
|
||||||
|
|
||||||
|
handler.identify('user-1', 'Alice One', undefined, {
|
||||||
|
description: 'New bio',
|
||||||
|
profileUpdatedAt: 123456
|
||||||
|
});
|
||||||
|
|
||||||
|
const identifyMessages = sentMessages.filter((entry) => entry['type'] === SIGNALING_TYPE_IDENTIFY);
|
||||||
|
|
||||||
|
expect(identifyMessages.length).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
for (const message of identifyMessages) {
|
||||||
|
expect(message['displayName']).toBe('Alice One');
|
||||||
|
expect(message['profileUpdatedAt']).toBe(123456);
|
||||||
|
expect(message['description']).toBe('New bio');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to the stored credential display name when none is supplied', () => {
|
||||||
|
const { handler, sentMessages } = createHandler({
|
||||||
|
userId: 'user-1',
|
||||||
|
token: 'token-1',
|
||||||
|
displayName: 'Alice'
|
||||||
|
});
|
||||||
|
|
||||||
|
handler.identify('user-1', ' ', undefined);
|
||||||
|
|
||||||
|
const identifyMessages = sentMessages.filter((entry) => entry['type'] === SIGNALING_TYPE_IDENTIFY);
|
||||||
|
|
||||||
|
expect(identifyMessages.length).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
for (const message of identifyMessages) {
|
||||||
|
expect(message['displayName']).toBe('Alice');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -226,7 +226,7 @@ export class SignalingTransportHandler<TMessage> {
|
|||||||
signalUrl?: string,
|
signalUrl?: string,
|
||||||
profile?: Pick<IdentifyCredentials, 'description' | 'profileUpdatedAt' | 'homeSignalServerUrl'>
|
profile?: Pick<IdentifyCredentials, 'description' | 'profileUpdatedAt' | 'homeSignalServerUrl'>
|
||||||
): void {
|
): void {
|
||||||
const normalizedDisplayName = displayName.trim() || DEFAULT_DISPLAY_NAME;
|
const trimmedDisplayName = displayName.trim();
|
||||||
const normalizedDescription = typeof profile?.description === 'string'
|
const normalizedDescription = typeof profile?.description === 'string'
|
||||||
? (profile.description.trim() || undefined)
|
? (profile.description.trim() || undefined)
|
||||||
: undefined;
|
: undefined;
|
||||||
@@ -243,7 +243,7 @@ export class SignalingTransportHandler<TMessage> {
|
|||||||
if (signalUrl) {
|
if (signalUrl) {
|
||||||
this.identifyOnSignalUrl(signalUrl, {
|
this.identifyOnSignalUrl(signalUrl, {
|
||||||
fallbackOderId: oderId,
|
fallbackOderId: oderId,
|
||||||
displayName: normalizedDisplayName,
|
displayName: trimmedDisplayName,
|
||||||
description: normalizedDescription,
|
description: normalizedDescription,
|
||||||
profileUpdatedAt: normalizedProfileUpdatedAt,
|
profileUpdatedAt: normalizedProfileUpdatedAt,
|
||||||
homeSignalServerUrl: normalizedHomeSignalServerUrl,
|
homeSignalServerUrl: normalizedHomeSignalServerUrl,
|
||||||
@@ -262,7 +262,7 @@ export class SignalingTransportHandler<TMessage> {
|
|||||||
for (const { signalUrl: managerSignalUrl, manager } of connectedManagers) {
|
for (const { signalUrl: managerSignalUrl, manager } of connectedManagers) {
|
||||||
const credentials = this.identifyOnSignalUrl(managerSignalUrl, {
|
const credentials = this.identifyOnSignalUrl(managerSignalUrl, {
|
||||||
fallbackOderId: oderId,
|
fallbackOderId: oderId,
|
||||||
displayName: normalizedDisplayName,
|
displayName: trimmedDisplayName,
|
||||||
description: normalizedDescription,
|
description: normalizedDescription,
|
||||||
profileUpdatedAt: normalizedProfileUpdatedAt,
|
profileUpdatedAt: normalizedProfileUpdatedAt,
|
||||||
homeSignalServerUrl: normalizedHomeSignalServerUrl,
|
homeSignalServerUrl: normalizedHomeSignalServerUrl,
|
||||||
@@ -312,7 +312,7 @@ export class SignalingTransportHandler<TMessage> {
|
|||||||
const credentials: IdentifyCredentials = {
|
const credentials: IdentifyCredentials = {
|
||||||
oderId: resolved.userId,
|
oderId: resolved.userId,
|
||||||
token: resolved.token,
|
token: resolved.token,
|
||||||
displayName: resolved.displayName || params.displayName,
|
displayName: params.displayName || resolved.displayName || DEFAULT_DISPLAY_NAME,
|
||||||
description: params.description,
|
description: params.description,
|
||||||
profileUpdatedAt: params.profileUpdatedAt,
|
profileUpdatedAt: params.profileUpdatedAt,
|
||||||
homeSignalServerUrl: params.homeSignalServerUrl ?? resolved.homeSignalServerUrl,
|
homeSignalServerUrl: params.homeSignalServerUrl ?? resolved.homeSignalServerUrl,
|
||||||
|
|||||||
@@ -79,4 +79,38 @@ describe('user avatar sync helpers', () => {
|
|||||||
updatedAt: 100
|
updatedAt: 100
|
||||||
})).toBe(false);
|
})).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('requests data when only the remote profile text is newer and no avatar exists', () => {
|
||||||
|
const existingUser = createUser({
|
||||||
|
displayName: 'Alice',
|
||||||
|
profileUpdatedAt: 100
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(shouldRequestAvatarData(existingUser, {
|
||||||
|
avatarUpdatedAt: 0,
|
||||||
|
profileUpdatedAt: 200
|
||||||
|
})).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not request profile data when the local profile is the same or newer', () => {
|
||||||
|
const existingUser = createUser({ profileUpdatedAt: 200 });
|
||||||
|
|
||||||
|
expect(shouldRequestAvatarData(existingUser, {
|
||||||
|
avatarUpdatedAt: 0,
|
||||||
|
profileUpdatedAt: 200
|
||||||
|
})).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies profile-only transfers when the remote profile is newer', () => {
|
||||||
|
const existingUser = createUser({
|
||||||
|
displayName: 'Alice',
|
||||||
|
profileUpdatedAt: 100
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(shouldApplyAvatarTransfer(existingUser, {
|
||||||
|
hash: undefined,
|
||||||
|
updatedAt: 0,
|
||||||
|
profileUpdatedAt: 200
|
||||||
|
})).toBe(true);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ interface PendingAvatarTransfer {
|
|||||||
hash?: string;
|
hash?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
type AvatarVersionState = Pick<User, 'avatarUrl' | 'avatarHash' | 'avatarUpdatedAt'> | undefined;
|
type AvatarVersionState = Pick<User, 'avatarUrl' | 'avatarHash' | 'avatarUpdatedAt' | 'profileUpdatedAt'> | undefined;
|
||||||
type RoomProfileState = Pick<User,
|
type RoomProfileState = Pick<User,
|
||||||
| 'id'
|
| 'id'
|
||||||
| 'oderId'
|
| 'oderId'
|
||||||
@@ -80,22 +80,31 @@ function shouldAcceptAvatarPayload(
|
|||||||
return !!incomingHash && incomingHash !== existingUser.avatarHash;
|
return !!incomingHash && incomingHash !== existingUser.avatarHash;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function shouldAcceptProfilePayload(
|
||||||
|
existingUser: Pick<User, 'profileUpdatedAt'> | undefined,
|
||||||
|
incomingProfileUpdatedAt: number | undefined
|
||||||
|
): boolean {
|
||||||
|
return (incomingProfileUpdatedAt ?? 0) > (existingUser?.profileUpdatedAt ?? 0);
|
||||||
|
}
|
||||||
|
|
||||||
function hasSyncableUserData(user: Pick<User, 'avatarUpdatedAt' | 'profileUpdatedAt'> | null | undefined): boolean {
|
function hasSyncableUserData(user: Pick<User, 'avatarUpdatedAt' | 'profileUpdatedAt'> | null | undefined): boolean {
|
||||||
return (user?.avatarUpdatedAt ?? 0) > 0;
|
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 shouldAcceptAvatarPayload(existingUser, incomingAvatar.avatarUpdatedAt ?? 0, incomingAvatar.avatarHash)
|
||||||
|
|| shouldAcceptProfilePayload(existingUser, incomingAvatar.profileUpdatedAt);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function shouldApplyAvatarTransfer(
|
export function shouldApplyAvatarTransfer(
|
||||||
existingUser: AvatarVersionState,
|
existingUser: AvatarVersionState,
|
||||||
transfer: Pick<PendingAvatarTransfer, 'hash' | 'updatedAt'>
|
transfer: Pick<PendingAvatarTransfer, 'hash' | 'updatedAt' | 'profileUpdatedAt'>
|
||||||
): boolean {
|
): boolean {
|
||||||
return shouldAcceptAvatarPayload(existingUser, transfer.updatedAt, transfer.hash);
|
return shouldAcceptAvatarPayload(existingUser, transfer.updatedAt, transfer.hash)
|
||||||
|
|| shouldAcceptProfilePayload(existingUser, transfer.profileUpdatedAt);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -258,17 +267,18 @@ export class UserAvatarEffects {
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
private buildAvatarSummary(user: Pick<User, 'oderId' | 'id' | 'avatarHash' | 'avatarUpdatedAt'>): ChatEvent {
|
private buildAvatarSummary(user: Pick<User, 'oderId' | 'id' | 'avatarHash' | 'avatarUpdatedAt' | 'profileUpdatedAt'>): ChatEvent {
|
||||||
return {
|
return {
|
||||||
type: 'user-avatar-summary',
|
type: 'user-avatar-summary',
|
||||||
oderId: user.oderId || user.id,
|
oderId: user.oderId || user.id,
|
||||||
avatarHash: user.avatarHash,
|
avatarHash: user.avatarHash,
|
||||||
avatarUpdatedAt: user.avatarUpdatedAt || 0
|
avatarUpdatedAt: user.avatarUpdatedAt || 0,
|
||||||
|
profileUpdatedAt: user.profileUpdatedAt || 0
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private handleAvatarSummary(event: ChatEvent, allUsers: User[], currentUser: User | null) {
|
private handleAvatarSummary(event: ChatEvent, allUsers: User[], currentUser: User | null) {
|
||||||
if (!event.fromPeerId || !event.oderId || !event.avatarUpdatedAt) {
|
if (!event.fromPeerId || !event.oderId || (!event.avatarUpdatedAt && !event.profileUpdatedAt)) {
|
||||||
return EMPTY;
|
return EMPTY;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -467,6 +477,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,
|
||||||
|
|||||||
@@ -178,6 +178,7 @@ function buildInactiveCameraState(user: User): User['cameraState'] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function buildPresenceAwareUser(existingUser: User | undefined, incomingUser: User): User {
|
function buildPresenceAwareUser(existingUser: User | undefined, incomingUser: User): User {
|
||||||
|
|
||||||
const presenceServerIds = mergePresenceServerIds(existingUser?.presenceServerIds, incomingUser.presenceServerIds);
|
const presenceServerIds = mergePresenceServerIds(existingUser?.presenceServerIds, incomingUser.presenceServerIds);
|
||||||
const isOnline = (presenceServerIds?.length ?? 0) > 0 || incomingUser.isOnline === true;
|
const isOnline = (presenceServerIds?.length ?? 0) > 0 || incomingUser.isOnline === true;
|
||||||
const status = isOnline
|
const status = isOnline
|
||||||
|
|||||||
Reference in New Issue
Block a user