fix: Bug - Same user logged in on multiple clients acts like 2 different users

Collapse home and signal-server actor aliases into one canonical room member so multi-device sessions no longer duplicate the local user in the members panel.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-12 00:52:59 +02:00
parent 07e91a0d09
commit e75b4a38ed
3 changed files with 140 additions and 21 deletions

View File

@@ -31,6 +31,14 @@ test.describe('Multi-device session', () => {
expect(instanceA).not.toEqual(instanceB); 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 test.step('syncs chat from device A to device B', async () => {
await expectCrossDeviceMessage(scenario.messagesA, scenario.messagesB, messageAtoB); await expectCrossDeviceMessage(scenario.messagesA, scenario.messagesB, messageAtoB);
}); });

View File

@@ -26,6 +26,7 @@ import { selectCurrentRoom, selectSavedRooms } from './rooms.selectors';
import { normalizeRoomAccessControl, resolveLegacyRole } from '../../domains/access-control'; import { normalizeRoomAccessControl, resolveLegacyRole } from '../../domains/access-control';
import { import {
areRoomMembersEqual, areRoomMembersEqual,
collapseSelfRoomMembers,
findRoomMember, findRoomMember,
mergeRoomMembers, mergeRoomMembers,
pruneRoomMembers, pruneRoomMembers,
@@ -36,6 +37,7 @@ import {
updateRoomMemberRole, updateRoomMemberRole,
upsertRoomMember upsertRoomMember
} from './room-members.helpers'; } from './room-members.helpers';
import { resolveRoomMemberActorIdentity } from './room-member-identity.rules';
import { SignalServerAuthService } from '../../domains/authentication/application/services/signal-server-auth.service'; import { SignalServerAuthService } from '../../domains/authentication/application/services/signal-server-auth.service';
import { isSelfPresenceUserId } from '../../domains/authentication/domain/logic/self-presence-identity.rules'; import { isSelfPresenceUserId } from '../../domains/authentication/domain/logic/self-presence-identity.rules';
@@ -63,7 +65,7 @@ export class RoomMembersSyncEffects {
room.members ?? [], room.members ?? [],
this.buildCurrentUserMember(room, currentUser, true) 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; return actions.length > 0 ? actions : EMPTY;
}) })
@@ -93,7 +95,7 @@ export class RoomMembersSyncEffects {
currentRoom.members ?? [], currentRoom.members ?? [],
this.buildCurrentUserMember(currentRoom, currentUser, true) 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; return actions.length > 0 ? actions : EMPTY;
}) })
@@ -104,13 +106,20 @@ export class RoomMembersSyncEffects {
syncRoleChangesIntoCurrentRoom$ = createEffect(() => syncRoleChangesIntoCurrentRoom$ = createEffect(() =>
this.actions$.pipe( this.actions$.pipe(
ofType(UsersActions.updateUserRole), ofType(UsersActions.updateUserRole),
withLatestFrom(this.store.select(selectCurrentRoom)), withLatestFrom(
mergeMap(([{ userId, role }, currentRoom]) => { this.store.select(selectCurrentRoom),
this.store.select(selectCurrentUser)
),
mergeMap(([
{ userId, role },
currentRoom,
currentUser
]) => {
if (!currentRoom) if (!currentRoom)
return EMPTY; return EMPTY;
const members = updateRoomMemberRole(currentRoom.members ?? [], userId, role); 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; return actions.length > 0 ? actions : EMPTY;
}) })
@@ -194,7 +203,12 @@ export class RoomMembersSyncEffects {
members = upsertRoomMember(members, this.buildPresenceMember(room, user)); 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; return actions.length > 0 ? actions : EMPTY;
} }
@@ -211,7 +225,12 @@ export class RoomMembersSyncEffects {
room.members ?? [], room.members ?? [],
this.buildPresenceMember(room, joinedUser) 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; return actions.length > 0 ? actions : EMPTY;
} }
@@ -221,7 +240,12 @@ export class RoomMembersSyncEffects {
return EMPTY; return EMPTY;
const members = touchRoomMemberLastSeen(room.members ?? [], signalingMessage.oderId, Date.now()); 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; return actions.length > 0 ? actions : EMPTY;
} }
@@ -339,15 +363,30 @@ export class RoomMembersSyncEffects {
} }
private buildCurrentUserMember(room: Room, currentUser: User, isCurrentRoom: boolean): RoomMember { private buildCurrentUserMember(room: Room, currentUser: User, isCurrentRoom: boolean): RoomMember {
const existingMember = findRoomMember(room.members ?? [], currentUser.oderId || currentUser.id); const selfIds = this.signalServerAuth.resolveSelfPresenceUserIdsForRoom(currentUser, room.sourceUrl);
const role = room.hostId === currentUser.id 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' ? 'host'
: (isCurrentRoom ? currentUser.role : existingMember?.role ?? 'member'); : (isCurrentRoom ? currentUser.role : existingMember?.role ?? 'member');
const seenAt = existingMember?.lastSeenAt ?? currentUser.joinedAt ?? Date.now(); const seenAt = existingMember?.lastSeenAt ?? currentUser.joinedAt ?? Date.now();
return { return {
...roomMemberFromUser(currentUser, seenAt, role), ...roomMemberFromUser(currentUser, seenAt, role),
id: existingMember?.id ?? currentUser.id, id: identity.existingMemberId ?? actorUserId,
oderId: actorUserId,
joinedAt: existingMember?.joinedAt ?? currentUser.joinedAt ?? seenAt, joinedAt: existingMember?.joinedAt ?? currentUser.joinedAt ?? seenAt,
avatarUrl: currentUser.avatarUrl ?? existingMember?.avatarUrl, avatarUrl: currentUser.avatarUrl ?? existingMember?.avatarUrl,
role role
@@ -375,10 +414,24 @@ export class RoomMembersSyncEffects {
}; };
} }
private createRoomMemberUpdateActions(room: Room, members: RoomMember[]): Action[] { private createRoomMemberUpdateActions(
return areRoomMembersEqual(room.members ?? [], members) 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( private resolveRoleSyncRoom(
@@ -491,7 +544,7 @@ export class RoomMembersSyncEffects {
members members
}); });
return this.createRoomMemberUpdateActions(room, members); return this.createRoomMemberUpdateActions(room, members, currentUser, isCurrentRoom);
} }
private handleMemberRoster( 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; const currentUserId = currentUser?.oderId || currentUser?.id;
for (const member of members) { for (const member of members) {
@@ -551,7 +609,9 @@ export class RoomMembersSyncEffects {
const actions = this.createRoomMemberUpdateActions( const actions = this.createRoomMemberUpdateActions(
room, 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; const departedUserId = event.oderId ?? event.targetUserId;
@@ -652,7 +712,9 @@ export class RoomMembersSyncEffects {
const actions = this.createRoomMemberUpdateActions( const actions = this.createRoomMemberUpdateActions(
room, 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) { if (currentRoom?.id === room.id) {

View File

@@ -183,8 +183,12 @@ function mergeMembers(
const avatarFields = mergeAvatarFields(normalizedExisting, normalizedIncoming, preferIncoming); const avatarFields = mergeAvatarFields(normalizedExisting, normalizedIncoming, preferIncoming);
return { return {
id: normalizedExisting.id || normalizedIncoming.id, id: preferIncoming
oderId: normalizedIncoming.oderId || normalizedExisting.oderId, ? (normalizedIncoming.id || normalizedExisting.id)
: (normalizedExisting.id || normalizedIncoming.id),
oderId: preferIncoming
? (normalizedIncoming.oderId || normalizedExisting.oderId)
: (normalizedExisting.oderId || normalizedIncoming.oderId),
username: preferIncoming username: preferIncoming
? (normalizedIncoming.username || normalizedExisting.username) ? (normalizedIncoming.username || normalizedExisting.username)
: (normalizedExisting.username || normalizedIncoming.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<string, RoomMember>();
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<string>
): 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<string>,
canonicalMember: RoomMember,
now = Date.now()
): RoomMember[] {
const withoutSelfAliases = removeRoomMembersMatchingIds(members, selfIds);
return upsertRoomMember(withoutSelfAliases, canonicalMember, now);
}
/** Deduplicate, sanitize, sort, and prune stale room members. */ /** Deduplicate, sanitize, sort, and prune stale room members. */
export function pruneRoomMembers( export function pruneRoomMembers(
members: RoomMember[] = [], 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. */ /** Upsert a member into a room roster while preserving the best known data. */