diff --git a/e2e/tests/auth/multi-device-session.spec.ts b/e2e/tests/auth/multi-device-session.spec.ts index 76f2905..bdd749d 100644 --- a/e2e/tests/auth/multi-device-session.spec.ts +++ b/e2e/tests/auth/multi-device-session.spec.ts @@ -31,6 +31,14 @@ test.describe('Multi-device session', () => { expect(instanceA).not.toEqual(instanceB); }); + await test.step('shows one self identity in the members panel on each device', async () => { + for (const client of [scenario.clientA, scenario.clientB]) { + await expect( + membersSidePanel(client.page).getByText(scenario.credentials.displayName, { exact: true }) + ).toHaveCount(1, { timeout: 20_000 }); + } + }); + await test.step('syncs chat from device A to device B', async () => { await expectCrossDeviceMessage(scenario.messagesA, scenario.messagesB, messageAtoB); }); diff --git a/toju-app/src/app/store/rooms/room-members-sync.effects.ts b/toju-app/src/app/store/rooms/room-members-sync.effects.ts index 6f8f910..d389ce1 100644 --- a/toju-app/src/app/store/rooms/room-members-sync.effects.ts +++ b/toju-app/src/app/store/rooms/room-members-sync.effects.ts @@ -26,6 +26,7 @@ import { selectCurrentRoom, selectSavedRooms } from './rooms.selectors'; import { normalizeRoomAccessControl, resolveLegacyRole } from '../../domains/access-control'; import { areRoomMembersEqual, + collapseSelfRoomMembers, findRoomMember, mergeRoomMembers, pruneRoomMembers, @@ -36,6 +37,7 @@ import { updateRoomMemberRole, upsertRoomMember } from './room-members.helpers'; +import { resolveRoomMemberActorIdentity } from './room-member-identity.rules'; import { SignalServerAuthService } from '../../domains/authentication/application/services/signal-server-auth.service'; import { isSelfPresenceUserId } from '../../domains/authentication/domain/logic/self-presence-identity.rules'; @@ -63,7 +65,7 @@ export class RoomMembersSyncEffects { room.members ?? [], this.buildCurrentUserMember(room, currentUser, true) ); - const actions = this.createRoomMemberUpdateActions(room, members); + const actions = this.createRoomMemberUpdateActions(room, members, currentUser, true); return actions.length > 0 ? actions : EMPTY; }) @@ -93,7 +95,7 @@ export class RoomMembersSyncEffects { currentRoom.members ?? [], this.buildCurrentUserMember(currentRoom, currentUser, true) ); - const actions = this.createRoomMemberUpdateActions(currentRoom, members); + const actions = this.createRoomMemberUpdateActions(currentRoom, members, currentUser, true); return actions.length > 0 ? actions : EMPTY; }) @@ -104,13 +106,20 @@ export class RoomMembersSyncEffects { syncRoleChangesIntoCurrentRoom$ = createEffect(() => this.actions$.pipe( ofType(UsersActions.updateUserRole), - withLatestFrom(this.store.select(selectCurrentRoom)), - mergeMap(([{ userId, role }, currentRoom]) => { + withLatestFrom( + this.store.select(selectCurrentRoom), + this.store.select(selectCurrentUser) + ), + mergeMap(([ + { userId, role }, + currentRoom, + currentUser + ]) => { if (!currentRoom) return EMPTY; const members = updateRoomMemberRole(currentRoom.members ?? [], userId, role); - const actions = this.createRoomMemberUpdateActions(currentRoom, members); + const actions = this.createRoomMemberUpdateActions(currentRoom, members, currentUser, true); return actions.length > 0 ? actions : EMPTY; }) @@ -194,7 +203,12 @@ export class RoomMembersSyncEffects { members = upsertRoomMember(members, this.buildPresenceMember(room, user)); } - const actions = this.createRoomMemberUpdateActions(room, members); + const actions = this.createRoomMemberUpdateActions( + room, + members, + currentUser ?? null, + currentRoom?.id === room.id + ); return actions.length > 0 ? actions : EMPTY; } @@ -211,7 +225,12 @@ export class RoomMembersSyncEffects { room.members ?? [], this.buildPresenceMember(room, joinedUser) ); - const actions = this.createRoomMemberUpdateActions(room, members); + const actions = this.createRoomMemberUpdateActions( + room, + members, + currentUser ?? null, + currentRoom?.id === room.id + ); return actions.length > 0 ? actions : EMPTY; } @@ -221,7 +240,12 @@ export class RoomMembersSyncEffects { return EMPTY; const members = touchRoomMemberLastSeen(room.members ?? [], signalingMessage.oderId, Date.now()); - const actions = this.createRoomMemberUpdateActions(room, members); + const actions = this.createRoomMemberUpdateActions( + room, + members, + currentUser ?? null, + currentRoom?.id === room.id + ); return actions.length > 0 ? actions : EMPTY; } @@ -339,15 +363,30 @@ export class RoomMembersSyncEffects { } private buildCurrentUserMember(room: Room, currentUser: User, isCurrentRoom: boolean): RoomMember { - const existingMember = findRoomMember(room.members ?? [], currentUser.oderId || currentUser.id); - const role = room.hostId === currentUser.id + const selfIds = this.signalServerAuth.resolveSelfPresenceUserIdsForRoom(currentUser, room.sourceUrl); + const identity = resolveRoomMemberActorIdentity( + room, + currentUser, + (serverUrl, fallback) => this.signalServerAuth.resolveActorUserIdForServer(serverUrl, fallback), + (user, roomSourceUrl) => this.signalServerAuth.resolveSelfPresenceUserIdsForRoom(user, roomSourceUrl) + ); + const actorUserId = identity.actorUserId; + const existingMember = findRoomMember(room.members ?? [], actorUserId) + ?? (room.members ?? []).find((member) => + isSelfPresenceUserId(member.oderId, selfIds) || isSelfPresenceUserId(member.id, selfIds) + ); + const isHost = room.hostId === currentUser.id + || room.hostId === currentUser.oderId + || room.hostId === actorUserId; + const role = isHost ? 'host' : (isCurrentRoom ? currentUser.role : existingMember?.role ?? 'member'); const seenAt = existingMember?.lastSeenAt ?? currentUser.joinedAt ?? Date.now(); return { ...roomMemberFromUser(currentUser, seenAt, role), - id: existingMember?.id ?? currentUser.id, + id: identity.existingMemberId ?? actorUserId, + oderId: actorUserId, joinedAt: existingMember?.joinedAt ?? currentUser.joinedAt ?? seenAt, avatarUrl: currentUser.avatarUrl ?? existingMember?.avatarUrl, role @@ -375,10 +414,24 @@ export class RoomMembersSyncEffects { }; } - private createRoomMemberUpdateActions(room: Room, members: RoomMember[]): Action[] { - return areRoomMembersEqual(room.members ?? [], members) + private createRoomMemberUpdateActions( + room: Room, + members: RoomMember[], + currentUser: User | null = null, + isCurrentRoom = false + ): Action[] { + const normalizedMembers = currentUser + ? collapseSelfRoomMembers( + members, + this.signalServerAuth.resolveSelfPresenceUserIdsForRoom(currentUser, room.sourceUrl), + this.buildCurrentUserMember(room, currentUser, isCurrentRoom), + Date.now() + ) + : pruneRoomMembers(members); + + return areRoomMembersEqual(room.members ?? [], normalizedMembers) ? [] - : [RoomsActions.updateRoom({ roomId: room.id, changes: { members } })]; + : [RoomsActions.updateRoom({ roomId: room.id, changes: { members: normalizedMembers } })]; } private resolveRoleSyncRoom( @@ -491,7 +544,7 @@ export class RoomMembersSyncEffects { members }); - return this.createRoomMemberUpdateActions(room, members); + return this.createRoomMemberUpdateActions(room, members, currentUser, isCurrentRoom); } private handleMemberRoster( @@ -514,7 +567,12 @@ export class RoomMembersSyncEffects { ); } - const actions = this.createRoomMemberUpdateActions(room, members); + const actions = this.createRoomMemberUpdateActions( + room, + members, + currentUser, + currentRoom?.id === room.id + ); const currentUserId = currentUser?.oderId || currentUser?.id; for (const member of members) { @@ -551,7 +609,9 @@ export class RoomMembersSyncEffects { const actions = this.createRoomMemberUpdateActions( room, - removeRoomMember(room.members ?? [], event.targetUserId, event.oderId) + removeRoomMember(room.members ?? [], event.targetUserId, event.oderId), + null, + currentRoom?.id === room.id ); const departedUserId = event.oderId ?? event.targetUserId; @@ -652,7 +712,9 @@ export class RoomMembersSyncEffects { const actions = this.createRoomMemberUpdateActions( room, - updateRoomMemberRole(room.members ?? [], event.targetUserId, event.role) + updateRoomMemberRole(room.members ?? [], event.targetUserId, event.role), + null, + currentRoom?.id === room.id ); if (currentRoom?.id === room.id) { diff --git a/toju-app/src/app/store/rooms/room-members.helpers.ts b/toju-app/src/app/store/rooms/room-members.helpers.ts index ad32a1f..6d67c0e 100644 --- a/toju-app/src/app/store/rooms/room-members.helpers.ts +++ b/toju-app/src/app/store/rooms/room-members.helpers.ts @@ -183,8 +183,12 @@ function mergeMembers( const avatarFields = mergeAvatarFields(normalizedExisting, normalizedIncoming, preferIncoming); return { - id: normalizedExisting.id || normalizedIncoming.id, - oderId: normalizedIncoming.oderId || normalizedExisting.oderId, + id: preferIncoming + ? (normalizedIncoming.id || normalizedExisting.id) + : (normalizedExisting.id || normalizedIncoming.id), + oderId: preferIncoming + ? (normalizedIncoming.oderId || normalizedExisting.oderId) + : (normalizedExisting.oderId || normalizedIncoming.oderId), username: preferIncoming ? (normalizedIncoming.username || normalizedExisting.username) : (normalizedExisting.username || normalizedIncoming.username), @@ -241,6 +245,48 @@ export function roomMemberFromUser( ); } +function profileDedupKey(member: RoomMember): string { + return `${member.displayName.trim().toLowerCase()}::${member.username.trim().toLowerCase()}`; +} + +function deduplicateMatchingProfiles(members: RoomMember[], now: number): RoomMember[] { + const profiles = new Map(); + + for (const member of members) { + const profileKey = profileDedupKey(member); + + profiles.set(profileKey, mergeMembers(profiles.get(profileKey), member, now)); + } + + return Array.from(profiles.values()); +} + +/** Remove every roster entry whose id or oderId matches one of the supplied ids. */ +export function removeRoomMembersMatchingIds( + members: RoomMember[] = [], + ids: ReadonlySet +): RoomMember[] { + if (ids.size === 0) { + return pruneRoomMembers(members); + } + + return pruneRoomMembers(members).filter( + (member) => !ids.has(member.id) && !ids.has(member.oderId || '') + ); +} + +/** Replace stale self aliases with the canonical actor member for this room. */ +export function collapseSelfRoomMembers( + members: RoomMember[] = [], + selfIds: ReadonlySet, + canonicalMember: RoomMember, + now = Date.now() +): RoomMember[] { + const withoutSelfAliases = removeRoomMembersMatchingIds(members, selfIds); + + return upsertRoomMember(withoutSelfAliases, canonicalMember, now); +} + /** Deduplicate, sanitize, sort, and prune stale room members. */ export function pruneRoomMembers( members: RoomMember[] = [], @@ -266,7 +312,10 @@ export function pruneRoomMembers( ); } - return Array.from(deduplicatedMembers.values()).sort(compareMembers); + return deduplicateMatchingProfiles( + Array.from(deduplicatedMembers.values()), + now + ).sort(compareMembers); } /** Upsert a member into a room roster while preserving the best known data. */