From 29521c5eb9ecbfee9129d401ff02b5507a8364df Mon Sep 17 00:00:00 2001 From: aditya-cherukuru Date: Mon, 12 Jan 2026 10:58:43 +0530 Subject: [PATCH 1/3] Fix reactive display name disambiguation When a room member changes their display name, recalculate the disambiguation flag for all other members who share (or previously shared) that display name. This ensures that the 'disambiguate' flag is updated reactively when display name conflicts appear or are resolved. Fixes element-hq/element-web#468 Fixes element-hq/element-web#4795 Fixes element-hq/element-web#31551 Signed-off-by: aditya-cherukuru --- spec/unit/room-state.spec.ts | 140 +++++++++++++++++++++++++++++++++++ src/models/room-state.ts | 48 ++++++++++++ 2 files changed, 188 insertions(+) diff --git a/spec/unit/room-state.spec.ts b/spec/unit/room-state.spec.ts index 0a5334cb241..3350eb2cc21 100644 --- a/spec/unit/room-state.spec.ts +++ b/spec/unit/room-state.spec.ts @@ -1308,4 +1308,144 @@ describe("RoomState", function () { ).toBeFalsy(); }); }); + + describe("reactive display name disambiguation", function () { + it("should disambiguate existing member when another member changes to the same name", function () { + // Create a fresh state + const testState = new RoomState(roomId); + + // Alice joins with display name "Alice" + const aliceJoinEvent = utils.mkMembership({ + user: userA, + mship: KnownMembership.Join, + room: roomId, + event: true, + name: "Alice", + }); + + // Bob joins with display name "Bob" + const bobJoinEvent = utils.mkMembership({ + user: userB, + mship: KnownMembership.Join, + room: roomId, + event: true, + name: "Bob", + }); + + testState.setStateEvents([aliceJoinEvent, bobJoinEvent]); + + // Verify no disambiguation needed initially + const aliceBefore = testState.getMember(userA); + const bobBefore = testState.getMember(userB); + expect(aliceBefore?.disambiguate).toBe(false); + expect(bobBefore?.disambiguate).toBe(false); + expect(aliceBefore?.name).toBe("Alice"); + expect(bobBefore?.name).toBe("Bob"); + + // Bob changes display name to "Alice" + const bobRenameEvent = utils.mkMembership({ + user: userB, + mship: KnownMembership.Join, + room: roomId, + event: true, + name: "Alice", + }); + + testState.setStateEvents([bobRenameEvent]); + + // Now both should be disambiguated + const aliceAfter = testState.getMember(userA); + const bobAfter = testState.getMember(userB); + expect(aliceAfter?.disambiguate).toBe(true); + expect(bobAfter?.disambiguate).toBe(true); + expect(aliceAfter?.name).toContain(userA); + expect(bobAfter?.name).toContain(userB); + }); + + it("should un-disambiguate member when conflicting member changes to different name", function () { + // Create a fresh state + const testState = new RoomState(roomId); + + // Both Alice and Bob join with display name "Alice" + const aliceJoinEvent = utils.mkMembership({ + user: userA, + mship: KnownMembership.Join, + room: roomId, + event: true, + name: "Alice", + }); + + const bobJoinEvent = utils.mkMembership({ + user: userB, + mship: KnownMembership.Join, + room: roomId, + event: true, + name: "Alice", + }); + + testState.setStateEvents([aliceJoinEvent, bobJoinEvent]); + + // Verify both are disambiguated + const aliceBefore = testState.getMember(userA); + const bobBefore = testState.getMember(userB); + expect(aliceBefore?.disambiguate).toBe(true); + expect(bobBefore?.disambiguate).toBe(true); + + // Bob changes display name to "Bob" + const bobRenameEvent = utils.mkMembership({ + user: userB, + mship: KnownMembership.Join, + room: roomId, + event: true, + name: "Bob", + }); + + testState.setStateEvents([bobRenameEvent]); + + // Alice should no longer be disambiguated, Bob should not be either + const aliceAfter = testState.getMember(userA); + const bobAfter = testState.getMember(userB); + expect(aliceAfter?.disambiguate).toBe(false); + expect(bobAfter?.disambiguate).toBe(false); + expect(aliceAfter?.name).toBe("Alice"); + expect(bobAfter?.name).toBe("Bob"); + }); + + it("should emit RoomState.members for affected members when disambiguation changes", function () { + // Create a fresh state + const testState = new RoomState(roomId); + + // Alice joins with display name "Alice" + const aliceJoinEvent = utils.mkMembership({ + user: userA, + mship: KnownMembership.Join, + room: roomId, + event: true, + name: "Alice", + }); + + testState.setStateEvents([aliceJoinEvent]); + + // Set up listener for Members event + const membersEmitted: string[] = []; + testState.on(RoomStateEvent.Members, (_ev, _state, member) => { + membersEmitted.push(member.userId); + }); + + // Bob joins with display name "Alice" - should trigger disambiguation for Alice + const bobJoinEvent = utils.mkMembership({ + user: userB, + mship: KnownMembership.Join, + room: roomId, + event: true, + name: "Alice", + }); + + testState.setStateEvents([bobJoinEvent]); + + // Both Alice and Bob should have emitted Members events + expect(membersEmitted).toContain(userA); + expect(membersEmitted).toContain(userB); + }); + }); }); diff --git a/src/models/room-state.ts b/src/models/room-state.ts index 25057a20afe..02e59eed558 100644 --- a/src/models/room-state.ts +++ b/src/models/room-state.ts @@ -1110,6 +1110,17 @@ export class RoomState extends TypedEventEmitter private updateDisplayNameCache(userId: string, displayName: string): void { const oldName = this.userIdsToDisplayNames[userId]; + + // Track which display names are affected by this change so we can + // recalculate disambiguation for all members with those names. + const affectedDisplayNames = new Set(); + if (oldName) { + const strippedOldName = removeHiddenChars(oldName); + if (strippedOldName) { + affectedDisplayNames.add(strippedOldName); + } + } + delete this.userIdsToDisplayNames[userId]; if (oldName) { // Remove the old name from the cache. @@ -1131,10 +1142,47 @@ export class RoomState extends TypedEventEmitter const strippedDisplayname = displayName && removeHiddenChars(displayName); // an empty stripped displayname (undefined/'') will be set to MXID in room-member.js if (strippedDisplayname) { + affectedDisplayNames.add(strippedDisplayname); const arr = this.displayNameToUserIds.get(strippedDisplayname) ?? []; arr.push(userId); this.displayNameToUserIds.set(strippedDisplayname, arr); } + + // Recalculate disambiguation for all members whose display names were affected. + // This ensures that when a user changes their name to match (or stop matching) + // another user, both users' disambiguation flags are updated correctly. + this.recalculateDisambiguationForDisplayNames(affectedDisplayNames, userId); + } + + /** + * Recalculate the `disambiguate` flag for all members with the given display names. + * This is called when a member's display name changes to ensure that other members + * who share (or previously shared) the same display name have their disambiguation + * flag updated correctly. + * + * @param displayNames - Set of stripped display names to check + * @param excludeUserId - User ID to exclude from recalculation (they're already being updated) + */ + private recalculateDisambiguationForDisplayNames(displayNames: Set, excludeUserId: string): void { + for (const displayName of displayNames) { + const userIds = this.displayNameToUserIds.get(displayName) ?? []; + for (const otherUserId of userIds) { + if (otherUserId === excludeUserId) continue; + + const member = this.members[otherUserId]; + if (member?.events.member) { + // Recalculate disambiguation by re-setting the membership event. + // This will call shouldDisambiguate() with the updated room state. + const oldName = member.name; + member.setMembershipEvent(member.events.member, this); + + // If the name changed, emit the Members event so the UI can update + if (oldName !== member.name) { + this.emit(RoomStateEvent.Members, member.events.member, this, member); + } + } + } + } } } From 440a24cf939793858c542f51a00a8634fc847dd1 Mon Sep 17 00:00:00 2001 From: aditya-cherukuru Date: Tue, 13 Jan 2026 11:03:07 +0530 Subject: [PATCH 2/3] Refactor: move disambiguation logic per review feedback - Added updateDisambiguation() method to RoomMember for direct disambiguation recalculation - Moved affected display name tracking to setStateEvents() instead of updateDisplayNameCache() - Removed setMembershipEvent() hack, now calls updateDisambiguation() directly Signed-off-by: aditya-cherukuru --- src/models/room-member.ts | 36 ++++++++++++++++ src/models/room-state.ts | 89 ++++++++++++++++++--------------------- 2 files changed, 77 insertions(+), 48 deletions(-) diff --git a/src/models/room-member.ts b/src/models/room-member.ts index 23070237769..16676c01d94 100644 --- a/src/models/room-member.ts +++ b/src/models/room-member.ts @@ -227,6 +227,42 @@ export class RoomMember extends TypedEventEmitter this.updateModifiedTime(); // update the core event dict + // Track display names that change so we can recalculate disambiguation + const affectedDisplayNames = new Set(); + stateEvents.forEach((event) => { if (event.getRoomId() !== this.roomId || !event.isState()) return; @@ -448,7 +451,21 @@ export class RoomState extends TypedEventEmitter const lastStateEvent = this.getStateEventMatching(event); this.setStateEvent(event); if (event.getType() === EventType.RoomMember) { - this.updateDisplayNameCache(event.getStateKey()!, event.getContent().displayname ?? ""); + const userId = event.getStateKey()!; + const newDisplayName = event.getContent().displayname ?? ""; + const oldDisplayName = this.userIdsToDisplayNames[userId]; + + // Track both old and new display names for disambiguation recalculation + if (oldDisplayName) { + const strippedOld = removeHiddenChars(oldDisplayName); + if (strippedOld) affectedDisplayNames.add(strippedOld); + } + if (newDisplayName) { + const strippedNew = removeHiddenChars(newDisplayName); + if (strippedNew) affectedDisplayNames.add(strippedNew); + } + + this.updateDisplayNameCache(userId, newDisplayName); this.updateThirdPartyTokenCache(event); } this.emit(RoomStateEvent.Events, event, this, lastStateEvent); @@ -514,6 +531,29 @@ export class RoomState extends TypedEventEmitter } }); + // Recalculate disambiguation for all members whose display names were affected. + // This ensures that when a user changes their name to match (or stop matching) + // another user, all affected users' disambiguation flags are updated correctly. + if (affectedDisplayNames.size > 0) { + // Collect all affected user IDs first to avoid duplicate processing + const affectedUserIds = new Set(); + for (const displayName of affectedDisplayNames) { + const userIds = this.displayNameToUserIds.get(displayName) ?? []; + userIds.forEach((id) => affectedUserIds.add(id)); + } + + // Process each affected member once + for (const userId of affectedUserIds) { + const member = this.members[userId]; + if (member?.events.member) { + const nameChanged = member.recalculateDisambiguatedName(this); + if (nameChanged) { + this.emit(RoomStateEvent.Members, member.events.member, this, member); + } + } + } + } + this.emit(RoomStateEvent.Update, this); } @@ -1111,16 +1151,6 @@ export class RoomState extends TypedEventEmitter private updateDisplayNameCache(userId: string, displayName: string): void { const oldName = this.userIdsToDisplayNames[userId]; - // Track which display names are affected by this change so we can - // recalculate disambiguation for all members with those names. - const affectedDisplayNames = new Set(); - if (oldName) { - const strippedOldName = removeHiddenChars(oldName); - if (strippedOldName) { - affectedDisplayNames.add(strippedOldName); - } - } - delete this.userIdsToDisplayNames[userId]; if (oldName) { // Remove the old name from the cache. @@ -1142,47 +1172,10 @@ export class RoomState extends TypedEventEmitter const strippedDisplayname = displayName && removeHiddenChars(displayName); // an empty stripped displayname (undefined/'') will be set to MXID in room-member.js if (strippedDisplayname) { - affectedDisplayNames.add(strippedDisplayname); const arr = this.displayNameToUserIds.get(strippedDisplayname) ?? []; arr.push(userId); this.displayNameToUserIds.set(strippedDisplayname, arr); } - - // Recalculate disambiguation for all members whose display names were affected. - // This ensures that when a user changes their name to match (or stop matching) - // another user, both users' disambiguation flags are updated correctly. - this.recalculateDisambiguationForDisplayNames(affectedDisplayNames, userId); - } - - /** - * Recalculate the `disambiguate` flag for all members with the given display names. - * This is called when a member's display name changes to ensure that other members - * who share (or previously shared) the same display name have their disambiguation - * flag updated correctly. - * - * @param displayNames - Set of stripped display names to check - * @param excludeUserId - User ID to exclude from recalculation (they're already being updated) - */ - private recalculateDisambiguationForDisplayNames(displayNames: Set, excludeUserId: string): void { - for (const displayName of displayNames) { - const userIds = this.displayNameToUserIds.get(displayName) ?? []; - for (const otherUserId of userIds) { - if (otherUserId === excludeUserId) continue; - - const member = this.members[otherUserId]; - if (member?.events.member) { - // Recalculate disambiguation by re-setting the membership event. - // This will call shouldDisambiguate() with the updated room state. - const oldName = member.name; - member.setMembershipEvent(member.events.member, this); - - // If the name changed, emit the Members event so the UI can update - if (oldName !== member.name) { - this.emit(RoomStateEvent.Members, member.events.member, this, member); - } - } - } - } } } From 235fe10da6e18e49b92173394afd8126402e4446 Mon Sep 17 00:00:00 2001 From: aditya-cherukuru Date: Thu, 15 Jan 2026 10:22:40 +0530 Subject: [PATCH 3/3] Exclude processed members from disambiguation loop Signed-off-by: aditya-cherukuru --- src/models/room-state.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/models/room-state.ts b/src/models/room-state.ts index c8b10264035..f8afdc4ac87 100644 --- a/src/models/room-state.ts +++ b/src/models/room-state.ts @@ -440,6 +440,8 @@ export class RoomState extends TypedEventEmitter // update the core event dict // Track display names that change so we can recalculate disambiguation const affectedDisplayNames = new Set(); + // Track userIds whose membership events we process so we don't emit duplicate events + const processedMemberUserIds = new Set(); stateEvents.forEach((event) => { if (event.getRoomId() !== this.roomId || !event.isState()) return; @@ -452,6 +454,7 @@ export class RoomState extends TypedEventEmitter this.setStateEvent(event); if (event.getType() === EventType.RoomMember) { const userId = event.getStateKey()!; + processedMemberUserIds.add(userId); const newDisplayName = event.getContent().displayname ?? ""; const oldDisplayName = this.userIdsToDisplayNames[userId]; @@ -542,8 +545,12 @@ export class RoomState extends TypedEventEmitter userIds.forEach((id) => affectedUserIds.add(id)); } - // Process each affected member once + // Process each affected member once, excluding those whose membership + // events were already processed (they already got their events emitted) for (const userId of affectedUserIds) { + if (processedMemberUserIds.has(userId)) { + continue; + } const member = this.members[userId]; if (member?.events.member) { const nameChanged = member.recalculateDisambiguatedName(this);