Skip to content

Commit 6b80941

Browse files
authored
feat(federation): notify user name changes (#39750)
1 parent eaf9266 commit 6b80941

18 files changed

Lines changed: 312 additions & 109 deletions

File tree

.changeset/five-chicken-invite.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
'@rocket.chat/federation-matrix': minor
3+
'@rocket.chat/meteor': minor
4+
---
5+
6+
Adds support to name changes on federated rooms

apps/meteor/ee/server/hooks/federation/index.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -353,3 +353,18 @@ callbacks.add(
353353
callbacks.priority.MEDIUM,
354354
'federation-read-receipt',
355355
);
356+
357+
callbacks.add('afterSaveUser', async ({ user: userUpdated, oldUser: oldUserData }) => {
358+
if (!userUpdated || !oldUserData) {
359+
return;
360+
}
361+
362+
if (isUserNativeFederated(userUpdated)) {
363+
// if the user is federated, it means the update came from Matrix, so we don't need to notify Matrix again
364+
return;
365+
}
366+
367+
if ('name' in userUpdated && userUpdated.name !== oldUserData.name) {
368+
void FederationMatrix.updateUserName(userUpdated);
369+
}
370+
});

apps/meteor/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,7 @@
103103
"@rocket.chat/emitter": "^0.32.0",
104104
"@rocket.chat/favicon": "workspace:^",
105105
"@rocket.chat/federation-matrix": "workspace:^",
106-
"@rocket.chat/federation-sdk": "0.4.3",
106+
"@rocket.chat/federation-sdk": "0.5.0",
107107
"@rocket.chat/fuselage": "^0.73.0",
108108
"@rocket.chat/fuselage-forms": "^1.0.0",
109109
"@rocket.chat/fuselage-hooks": "^0.40.0",

apps/meteor/server/methods/saveUserProfile.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import { passwordPolicy } from '../../app/lib/server/lib/passwordPolicy';
1616
import { setEmailFunction } from '../../app/lib/server/methods/setEmail';
1717
import { settings as rcSettings } from '../../app/settings/server';
1818
import { setUserStatusMethod } from '../../app/user-status/server/methods/setUserStatus';
19+
import { callbacks } from '../lib/callbacks';
1920
import { compareUserPassword } from '../lib/compareUserPassword';
2021
import { compareUserPasswordHistory } from '../lib/compareUserPasswordHistory';
2122

@@ -178,6 +179,11 @@ async function saveUserProfile(
178179
throw new Error('Unexpected error after saving user profile: user not found');
179180
}
180181

182+
await callbacks.run('afterSaveUser', {
183+
user: updatedUser,
184+
oldUser: user,
185+
});
186+
181187
void notifyOnUserChange({
182188
clientAction: 'updated',
183189
id: updatedUser._id,

apps/meteor/server/services/room/service.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,9 @@ export class RoomService extends ServiceClassInternal implements IRoomService {
4242
protected name = 'room';
4343

4444
async updateDirectMessageRoomName(room: IRoom, ignoreStatusFromSubs?: string[]): Promise<boolean> {
45+
if (room.t !== 'd') {
46+
throw new Error('Invalid room type');
47+
}
4548
const subs = await Subscriptions.findByRoomId(room._id, { projection: { u: 1, status: 1 } }).toArray();
4649

4750
const uids = subs.map((sub) => sub.u._id);

ee/packages/federation-matrix/package.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,14 +22,16 @@
2222
"@rocket.chat/core-services": "workspace:^",
2323
"@rocket.chat/core-typings": "workspace:^",
2424
"@rocket.chat/emitter": "^0.32.0",
25-
"@rocket.chat/federation-sdk": "0.4.3",
25+
"@rocket.chat/federation-sdk": "0.5.0",
2626
"@rocket.chat/http-router": "workspace:^",
2727
"@rocket.chat/license": "workspace:^",
2828
"@rocket.chat/models": "workspace:^",
2929
"@rocket.chat/network-broker": "workspace:^",
3030
"@rocket.chat/rest-typings": "workspace:^",
3131
"emojione": "^4.5.0",
32+
"lodash.debounce": "^4.0.8",
3233
"marked": "^16.1.2",
34+
"mem": "^8.1.1",
3335
"mongodb": "6.16.0",
3436
"pino": "10.3.1",
3537
"reflect-metadata": "^0.2.2",
@@ -40,6 +42,7 @@
4042
"devDependencies": {
4143
"@rocket.chat/ddp-client": "workspace:^",
4244
"@types/emojione": "^2.2.9",
45+
"@types/lodash.debounce": "^4.0.9",
4346
"@types/node": "~22.16.5",
4447
"@types/sanitize-html": "~2.16.0",
4548
"eslint": "~9.39.3",

ee/packages/federation-matrix/src/FederationMatrix.ts

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import {
77
isUserNativeFederated,
88
UserStatus,
99
} from '@rocket.chat/core-typings';
10-
import type { MessageQuoteAttachment, IMessage, IRoom, IUser, IRoomNativeFederated } from '@rocket.chat/core-typings';
10+
import type { MessageQuoteAttachment, IMessage, IRoom, IUser, IRoomNativeFederated, ISubscription } from '@rocket.chat/core-typings';
1111
import { eventIdSchema, roomIdSchema, userIdSchema, federationSDK, FederationRequestError } from '@rocket.chat/federation-sdk';
1212
import type { EventID, FileMessageType, PresenceState } from '@rocket.chat/federation-sdk';
1313
import { Logger } from '@rocket.chat/logger';
@@ -948,4 +948,33 @@ export class FederationMatrix extends ServiceClass implements IFederationMatrixS
948948
...(threadEventId && { threadId: eventIdSchema.parse(threadEventId) }),
949949
});
950950
}
951+
952+
// when a user changes their username, we need to send a new event for every room the user is a member
953+
async updateUserName(user: IUser): Promise<void> {
954+
const matrixUserId = userIdSchema.parse(`@${user.username}:${this.serverName}`);
955+
956+
const subs = await Subscriptions.findJoinedByUserId<Pick<ISubscription, 'rid'>>(user._id, { projection: { rid: 1 } }).toArray();
957+
958+
const rooms = await Rooms.findFederatedByIds<Pick<IRoomNativeFederated, '_id' | 'federation' | 'federated'>>(
959+
subs.map(({ rid }) => rid),
960+
{ projection: { _id: 1, federation: 1, federated: 1 } },
961+
).toArray();
962+
963+
await Promise.all(
964+
rooms.map(async ({ federation }) => {
965+
try {
966+
await federationSDK.updateRoomMembership({
967+
roomId: roomIdSchema.parse(federation.mrid),
968+
userId: matrixUserId,
969+
membership: 'join',
970+
content: {
971+
displayname: user.name || user.username,
972+
},
973+
});
974+
} catch (err) {
975+
this.logger.error({ msg: 'Failed to update username in Matrix for a room', roomId: federation.mrid, err });
976+
}
977+
}),
978+
);
979+
}
951980
}

ee/packages/federation-matrix/src/events/member.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ import type { IRoomNativeFederated, IRoom, IUser, RoomType } from '@rocket.chat/
44
import { federationSDK, type HomeserverEventSignatures, type PduForType } from '@rocket.chat/federation-sdk';
55
import { Logger } from '@rocket.chat/logger';
66
import { Rooms, Subscriptions, Users } from '@rocket.chat/models';
7+
import debounce from 'lodash.debounce';
8+
import mem from 'mem';
79

810
import { createOrUpdateFederatedUser } from '../helpers/createOrUpdateFederatedUser';
911
import { getUsernameServername } from '../helpers/getUsernameServername';
@@ -196,9 +198,16 @@ async function handleInvite({
196198
}
197199
}
198200

201+
const getUpdateUserNameDebounced = mem((userId: string) => debounce((name: string) => Users.setName(userId, name), 2000));
202+
203+
function updateUserNameDebounced(userId: string, newName: string): void {
204+
void getUpdateUserNameDebounced(userId)(newName);
205+
}
206+
199207
async function handleJoin({
200208
room_id: roomId,
201209
state_key: userId,
210+
content,
202211
}: HomeserverEventSignatures['homeserver.matrix.membership']['event']): Promise<void> {
203212
const joiningUser = await getOrCreateFederatedUser(userId);
204213
if (!joiningUser?.username) {
@@ -215,6 +224,13 @@ async function handleJoin({
215224
throw new Error(`Subscription not found while joining user ${userId} to room ${roomId}`);
216225
}
217226

227+
// updates user name whenever we receive a join event, because Matrix sends a new join event with the updated display name whenever a user changes their display name
228+
if ('displayname' in content && content.displayname !== joiningUser.name) {
229+
// whan a user changes the it's display name we receive a new join event for every room the user is in
230+
// so we need to debounce the name update to avoid updating the name multiple times in a row
231+
void updateUserNameDebounced(joiningUser._id, content.displayname || '');
232+
}
233+
218234
// update room name for DMs
219235
if (room.t === 'd') {
220236
await Room.updateDirectMessageRoomName(room, [subscription._id]);

ee/packages/federation-matrix/tests/end-to-end/dms.spec.ts

Lines changed: 20 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -126,7 +126,7 @@ const waitForRoomEvent = async (
126126
subscriptionInvite = await getSubscriptionByRoomId(rcRoom._id, rcUserConfig.credentials, rcUserConfig.request);
127127

128128
expect(subscriptionInvite).toHaveProperty('status', 'INVITED');
129-
expect(subscriptionInvite).toHaveProperty('fname', `@${federationConfig.hs1.adminUser}:${federationConfig.hs1.domain}`);
129+
expect(subscriptionInvite).toHaveProperty('fname', federationConfig.hs1.adminUser);
130130
});
131131
});
132132

@@ -153,7 +153,7 @@ const waitForRoomEvent = async (
153153
it('should display the fname properly', async () => {
154154
const sub = await getSubscriptionByRoomId(rcRoom._id, rcUserConfig.credentials, rcUserConfig.request);
155155

156-
expect(sub).toHaveProperty('fname', federationConfig.hs1.adminMatrixUserId);
156+
expect(sub).toHaveProperty('fname', federationConfig.hs1.adminUser);
157157
});
158158

159159
it('should return own user name as the room name when user is alone in the DM', async () => {
@@ -236,7 +236,7 @@ const waitForRoomEvent = async (
236236
subscriptionInvite = await getSubscriptionByRoomId(rcRoom._id, rcUserConfig.credentials, rcUserConfig.request);
237237

238238
expect(subscriptionInvite).toHaveProperty('status', 'INVITED');
239-
expect(subscriptionInvite).toHaveProperty('fname', `@${federationConfig.hs1.adminUser}:${federationConfig.hs1.domain}`);
239+
expect(subscriptionInvite).toHaveProperty('fname', federationConfig.hs1.adminUser);
240240
});
241241
});
242242

@@ -264,7 +264,7 @@ const waitForRoomEvent = async (
264264
it('should display the fname properly', async () => {
265265
const sub = await getSubscriptionByRoomId(rcRoom._id, rcUserConfig.credentials, rcUserConfig.request);
266266

267-
expect(sub).toHaveProperty('fname', federationConfig.hs1.adminMatrixUserId);
267+
expect(sub).toHaveProperty('fname', federationConfig.hs1.adminUser);
268268
});
269269

270270
it('should be able to leave the DM from Rocket.Chat', async () => {
@@ -412,7 +412,7 @@ const waitForRoomEvent = async (
412412
const sub = await getSubscriptionByRoomId(rcRoom._id, rcUserConfig.credentials, rcUserConfig.request);
413413

414414
// After acceptance, should display the Synapse user's ID
415-
expect(sub).toHaveProperty('fname', federationConfig.hs1.additionalUser1.matrixUserId);
415+
expect(sub).toHaveProperty('fname', federationConfig.hs1.additionalUser1.username);
416416
});
417417
});
418418

@@ -543,8 +543,7 @@ const waitForRoomEvent = async (
543543
pendingInvitation1 = await getSubscriptionByRoomId(rcRoom1._id, rcUserConfig1.credentials, rcUserConfig1.request);
544544

545545
expect(pendingInvitation1).toHaveProperty('status', 'INVITED');
546-
expect(pendingInvitation1).toHaveProperty('fname', `@${federationConfig.hs1.adminUser}:${federationConfig.hs1.domain}`);
547-
expect(pendingInvitation1).toHaveProperty('fname', federationConfig.hs1.adminMatrixUserId);
546+
expect(pendingInvitation1).toHaveProperty('fname', federationConfig.hs1.adminUser);
548547
});
549548

550549
it('should have user1 as regular user of the group DM on RC', async () => {
@@ -556,8 +555,7 @@ const waitForRoomEvent = async (
556555
pendingInvitation2 = await getSubscriptionByRoomId(rcRoom1._id, rcUserConfig2.credentials, rcUserConfig2.request);
557556

558557
expect(pendingInvitation2).toHaveProperty('status', 'INVITED');
559-
expect(pendingInvitation2).toHaveProperty('fname', `@${federationConfig.hs1.adminUser}:${federationConfig.hs1.domain}`);
560-
expect(pendingInvitation2).toHaveProperty('fname', federationConfig.hs1.adminMatrixUserId);
558+
expect(pendingInvitation2).toHaveProperty('fname', federationConfig.hs1.adminUser);
561559
});
562560

563561
it('should have user2 as regular user of the group DM on RC', async () => {
@@ -582,7 +580,7 @@ const waitForRoomEvent = async (
582580

583581
expect(sub).not.toHaveProperty('status');
584582
expect(sub).toHaveProperty('name', `${federationConfig.hs1.adminMatrixUserId}, ${userDm2}`);
585-
expect(sub).toHaveProperty('fname', `${federationConfig.hs1.adminMatrixUserId}, ${userDm2Name}`);
583+
expect(sub).toHaveProperty('fname', `${federationConfig.hs1.adminUser}, ${userDm2Name}`);
586584
},
587585
{ delayMs: 100 },
588586
);
@@ -778,7 +776,7 @@ const waitForRoomEvent = async (
778776
const pendingInvitationB = await getSubscriptionByRoomId(rcRoomConverted._id, rcUserConfigB.credentials, rcUserConfigB.request);
779777

780778
expect(pendingInvitationB).toHaveProperty('status', 'INVITED');
781-
expect(pendingInvitationB).toHaveProperty('fname', federationConfig.hs1.adminMatrixUserId);
779+
expect(pendingInvitationB).toHaveProperty('fname', federationConfig.hs1.adminUser);
782780
});
783781

784782
const membersInMatrix = await hs1RoomConverted.getMembers();
@@ -809,14 +807,14 @@ const waitForRoomEvent = async (
809807

810808
expect(subA).not.toHaveProperty('status');
811809
expect(subA).toHaveProperty('name', `${federationConfig.hs1.adminMatrixUserId}, ${userDmB}`);
812-
expect(subA).toHaveProperty('fname', `${federationConfig.hs1.adminMatrixUserId}, ${userDmBName}`);
810+
expect(subA).toHaveProperty('fname', `${federationConfig.hs1.adminUser}, ${userDmBName}`);
813811

814812
// Check userB's subscription
815813
const subB = await getSubscriptionByRoomId(rcRoomConverted._id, rcUserConfigB.credentials, rcUserConfigB.request);
816814

817815
expect(subB).not.toHaveProperty('status');
818816
expect(subB).toHaveProperty('name', `${federationConfig.hs1.adminMatrixUserId}, ${userDmA}`);
819-
expect(subB).toHaveProperty('fname', `${federationConfig.hs1.adminMatrixUserId}, ${userDmAName}`);
817+
expect(subB).toHaveProperty('fname', `${federationConfig.hs1.adminUser}, ${userDmAName}`);
820818
},
821819
{ delayMs: 100 },
822820
);
@@ -944,15 +942,15 @@ const waitForRoomEvent = async (
944942

945943
// Should contain both invited users in the name
946944
expect(sub).toHaveProperty('name', `${federationConfig.hs1.adminMatrixUserId}, ${rcUser2.username}`);
947-
expect(sub).toHaveProperty('fname', `${federationConfig.hs1.adminMatrixUserId}, ${rcUser2.fullName}`);
945+
expect(sub).toHaveProperty('fname', `${federationConfig.hs1.adminUser}, ${rcUser2.fullName}`);
948946
});
949947

950948
it("should display only the inviter's username for the invited user on Rocket.Chat", async () => {
951949
const sub = await getSubscriptionByRoomId(rcRoom._id, rcUser2.config.credentials, rcUser2.config.request);
952950

953951
expect(sub).toHaveProperty('status', 'INVITED');
954952
expect(sub).toHaveProperty('name', `${federationConfig.hs1.adminMatrixUserId}, ${rcUser1.username}`);
955-
expect(sub).toHaveProperty('fname', `${federationConfig.hs1.adminMatrixUserId}, ${rcUser1.fullName}`);
953+
expect(sub).toHaveProperty('fname', `${federationConfig.hs1.adminUser}, ${rcUser1.fullName}`);
956954
});
957955

958956
it('should accept the invitation on Synapse', async () => {
@@ -979,7 +977,7 @@ const waitForRoomEvent = async (
979977

980978
expect(sub).toHaveProperty('status', 'INVITED');
981979
expect(sub).toHaveProperty('name', `${federationConfig.hs1.adminMatrixUserId}, ${rcUser1.username}`);
982-
expect(sub).toHaveProperty('fname', `${federationConfig.hs1.adminMatrixUserId}, ${rcUser1.fullName}`);
980+
expect(sub).toHaveProperty('fname', `${federationConfig.hs1.adminUser}, ${rcUser1.fullName}`);
983981
},
984982
{ delayMs: 100 },
985983
);
@@ -1254,7 +1252,7 @@ const waitForRoomEvent = async (
12541252
const sub = await getSubscriptionByRoomId(rcRoom._id, rcUser2.config.credentials, rcUser2.config.request);
12551253

12561254
// After acceptance, should display the Synapse user's ID
1257-
expect(sub).toHaveProperty('fname', `${federationConfig.hs1.adminMatrixUserId}, ${rcUser1.fullName}`);
1255+
expect(sub).toHaveProperty('fname', `${federationConfig.hs1.adminUser}, ${rcUser1.fullName}`);
12581256
});
12591257

12601258
// Then create non-federated DM between rcUser1 and rcUser2 which should be returned on duplication
@@ -1439,7 +1437,7 @@ const waitForRoomEvent = async (
14391437
const sub = await getSubscriptionByRoomId(rcRoom1on1._id, rcUser1.config.credentials, rcUser1.config.request);
14401438

14411439
// After acceptance, should display the Synapse user's ID
1442-
expect(sub).toHaveProperty('fname', `${federationConfig.hs1.adminMatrixUserId}`);
1440+
expect(sub).toHaveProperty('fname', federationConfig.hs1.adminUser);
14431441
});
14441442
});
14451443

@@ -1489,7 +1487,7 @@ const waitForRoomEvent = async (
14891487
const sub = await getSubscriptionByRoomId(rcRoom._id, rcUser2.config.credentials, rcUser2.config.request);
14901488

14911489
// After acceptance, should display the Synapse user's ID
1492-
expect(sub).toHaveProperty('fname', `${federationConfig.hs1.adminMatrixUserId}, ${rcUser1.fullName}`);
1490+
expect(sub).toHaveProperty('fname', `${federationConfig.hs1.adminUser}, ${rcUser1.fullName}`);
14931491
},
14941492
{ delayMs: 100 },
14951493
);
@@ -1704,7 +1702,7 @@ const waitForRoomEvent = async (
17041702

17051703
// Should contain both invited users in the name
17061704
expect(sub).toHaveProperty('name', federationConfig.hs1.adminMatrixUserId);
1707-
expect(sub).toHaveProperty('fname', federationConfig.hs1.adminMatrixUserId);
1705+
expect(sub).toHaveProperty('fname', federationConfig.hs1.adminUser);
17081706
});
17091707

17101708
it('should send an invite to another Synapse user', async () => {
@@ -1740,10 +1738,7 @@ const waitForRoomEvent = async (
17401738
'name',
17411739
`${federationConfig.hs1.adminMatrixUserId}, ${federationConfig.hs1.additionalUser1.matrixUserId}`,
17421740
);
1743-
expect(subA).toHaveProperty(
1744-
'fname',
1745-
`${federationConfig.hs1.adminMatrixUserId}, ${federationConfig.hs1.additionalUser1.matrixUserId}`,
1746-
);
1741+
expect(subA).toHaveProperty('fname', `${federationConfig.hs1.adminUser}, ${federationConfig.hs1.additionalUser1.username}`);
17471742
},
17481743
{ delayMs: 100 },
17491744
);
@@ -1778,7 +1773,7 @@ const waitForRoomEvent = async (
17781773

17791774
expect(sub).toHaveProperty('status', 'INVITED');
17801775
expect(sub).toHaveProperty('name', federationConfig.hs1.additionalUser1.matrixUserId);
1781-
expect(sub).toHaveProperty('fname', federationConfig.hs1.additionalUser1.matrixUserId);
1776+
expect(sub).toHaveProperty('fname', federationConfig.hs1.additionalUser1.username);
17821777
},
17831778
{ delayMs: 100 },
17841779
);

0 commit comments

Comments
 (0)