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:
@@ -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);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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. */
|
||||||
|
|||||||
Reference in New Issue
Block a user