Skip to content

Commit aec454d

Browse files
authored
Add UserProfilesStore, LruCache and cache for user permalink profiles (matrix-org#10425)
1 parent 1c039fc commit aec454d

File tree

10 files changed

+923
-53
lines changed

10 files changed

+923
-53
lines changed

src/components/structures/MatrixChat.tsx

+1
Original file line numberDiff line numberDiff line change
@@ -1439,6 +1439,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
14391439
});
14401440
this.subTitleStatus = "";
14411441
this.setPageSubtitle();
1442+
this.stores.onLoggedOut();
14421443
}
14431444

14441445
/**

src/contexts/SDKContext.ts

+18
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import RightPanelStore from "../stores/right-panel/RightPanelStore";
2828
import { RoomViewStore } from "../stores/RoomViewStore";
2929
import SpaceStore, { SpaceStoreClass } from "../stores/spaces/SpaceStore";
3030
import TypingStore from "../stores/TypingStore";
31+
import { UserProfilesStore } from "../stores/UserProfilesStore";
3132
import { WidgetLayoutStore } from "../stores/widgets/WidgetLayoutStore";
3233
import { WidgetPermissionStore } from "../stores/widgets/WidgetPermissionStore";
3334
import WidgetStore from "../stores/WidgetStore";
@@ -75,6 +76,7 @@ export class SdkContextClass {
7576
protected _VoiceBroadcastPreRecordingStore?: VoiceBroadcastPreRecordingStore;
7677
protected _VoiceBroadcastPlaybacksStore?: VoiceBroadcastPlaybacksStore;
7778
protected _AccountPasswordStore?: AccountPasswordStore;
79+
protected _UserProfilesStore?: UserProfilesStore;
7880

7981
/**
8082
* Automatically construct stores which need to be created eagerly so they can register with
@@ -185,4 +187,20 @@ export class SdkContextClass {
185187
}
186188
return this._AccountPasswordStore;
187189
}
190+
191+
public get userProfilesStore(): UserProfilesStore {
192+
if (!this.client) {
193+
throw new Error("Unable to create UserProfilesStore without a client");
194+
}
195+
196+
if (!this._UserProfilesStore) {
197+
this._UserProfilesStore = new UserProfilesStore(this.client);
198+
}
199+
200+
return this._UserProfilesStore;
201+
}
202+
203+
public onLoggedOut(): void {
204+
this._UserProfilesStore = undefined;
205+
}
188206
}

src/hooks/usePermalinkMember.ts

+50-27
Original file line numberDiff line numberDiff line change
@@ -14,14 +14,29 @@ See the License for the specific language governing permissions and
1414
limitations under the License.
1515
*/
1616

17-
import { logger } from "matrix-js-sdk/src/logger";
18-
import { MatrixEvent, Room, RoomMember } from "matrix-js-sdk/src/matrix";
17+
import { IMatrixProfile, MatrixEvent, Room, RoomMember } from "matrix-js-sdk/src/matrix";
1918
import { useEffect, useState } from "react";
2019

2120
import { PillType } from "../components/views/elements/Pill";
22-
import { MatrixClientPeg } from "../MatrixClientPeg";
21+
import { SdkContextClass } from "../contexts/SDKContext";
2322
import { PermalinkParts } from "../utils/permalinks/PermalinkConstructor";
2423

24+
const createMemberFromProfile = (userId: string, profile: IMatrixProfile): RoomMember => {
25+
const member = new RoomMember("", userId);
26+
member.name = profile.displayname ?? userId;
27+
member.rawDisplayName = member.name;
28+
member.events.member = {
29+
getContent: () => {
30+
return { avatar_url: profile.avatar_url };
31+
},
32+
getDirectionalContent: function () {
33+
// eslint-disable-next-line
34+
return this.getContent();
35+
},
36+
} as MatrixEvent;
37+
return member;
38+
};
39+
2540
/**
2641
* Tries to determine the user Id of a permalink.
2742
* In case of a user permalink it is the user id.
@@ -49,6 +64,29 @@ const determineUserId = (
4964
return null;
5065
};
5166

67+
/**
68+
* Tries to determine a RoomMember.
69+
*
70+
* @param userId - User Id to get the member for
71+
* @param targetRoom - permalink target room
72+
* @returns RoomMember of the target room if it exists.
73+
* If sharing at least one room with the user, then the result will be the profile fetched via API.
74+
* null in all other cases.
75+
*/
76+
const determineMember = (userId: string, targetRoom: Room): RoomMember | null => {
77+
const targetRoomMember = targetRoom.getMember(userId);
78+
79+
if (targetRoomMember) return targetRoomMember;
80+
81+
const knownProfile = SdkContextClass.instance.userProfilesStore.getOnlyKnownProfile(userId);
82+
83+
if (knownProfile) {
84+
return createMemberFromProfile(userId, knownProfile);
85+
}
86+
87+
return null;
88+
};
89+
5290
/**
5391
* Hook to get the permalink member
5492
*
@@ -71,7 +109,7 @@ export const usePermalinkMember = (
71109
// If it cannot be initially determined, it will be looked up later by a memo hook.
72110
const shouldLookUpUser = type && [PillType.UserMention, PillType.EventInSameRoom].includes(type);
73111
const userId = determineUserId(type, parseResult, event);
74-
const userInRoom = shouldLookUpUser && userId && targetRoom ? targetRoom.getMember(userId) : null;
112+
const userInRoom = shouldLookUpUser && userId && targetRoom ? determineMember(userId, targetRoom) : null;
75113
const [member, setMember] = useState<RoomMember | null>(userInRoom);
76114

77115
useEffect(() => {
@@ -80,31 +118,16 @@ export const usePermalinkMember = (
80118
return;
81119
}
82120

83-
const doProfileLookup = (userId: string): void => {
84-
MatrixClientPeg.get()
85-
.getProfileInfo(userId)
86-
.then((resp) => {
87-
const newMember = new RoomMember("", userId);
88-
newMember.name = resp.displayname || userId;
89-
newMember.rawDisplayName = resp.displayname || userId;
90-
newMember.getMxcAvatarUrl();
91-
newMember.events.member = {
92-
getContent: () => {
93-
return { avatar_url: resp.avatar_url };
94-
},
95-
getDirectionalContent: function () {
96-
// eslint-disable-next-line
97-
return this.getContent();
98-
},
99-
} as MatrixEvent;
100-
setMember(newMember);
101-
})
102-
.catch((err) => {
103-
logger.error("Could not retrieve profile data for " + userId + ":", err);
104-
});
121+
const doProfileLookup = async (): Promise<void> => {
122+
const fetchedProfile = await SdkContextClass.instance.userProfilesStore.fetchOnlyKnownProfile(userId);
123+
124+
if (fetchedProfile) {
125+
const newMember = createMemberFromProfile(userId, fetchedProfile);
126+
setMember(newMember);
127+
}
105128
};
106129

107-
doProfileLookup(userId);
130+
doProfileLookup();
108131
}, [member, shouldLookUpUser, targetRoom, userId]);
109132

110133
return member;

src/stores/UserProfilesStore.ts

+150
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
/*
2+
Copyright 2023 The Matrix.org Foundation C.I.C.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
import { logger } from "matrix-js-sdk/src/logger";
18+
import { IMatrixProfile, MatrixClient, MatrixEvent, RoomMember, RoomMemberEvent } from "matrix-js-sdk/src/matrix";
19+
20+
import { LruCache } from "../utils/LruCache";
21+
22+
const cacheSize = 500;
23+
24+
type StoreProfileValue = IMatrixProfile | undefined | null;
25+
26+
/**
27+
* This store provides cached access to user profiles.
28+
* Listens for membership events and invalidates the cache for a profile on update with different profile values.
29+
*/
30+
export class UserProfilesStore {
31+
private profiles = new LruCache<string, IMatrixProfile | null>(cacheSize);
32+
private knownProfiles = new LruCache<string, IMatrixProfile | null>(cacheSize);
33+
34+
public constructor(private client: MatrixClient) {
35+
client.on(RoomMemberEvent.Membership, this.onRoomMembershipEvent);
36+
}
37+
38+
/**
39+
* Synchronously get a profile from the store cache.
40+
*
41+
* @param userId - User Id of the profile to fetch
42+
* @returns The profile, if cached by the store.
43+
* Null if the profile does not exist.
44+
* Undefined if the profile is not cached by the store.
45+
* In this case a profile can be fetched from the API via {@link fetchProfile}.
46+
*/
47+
public getProfile(userId: string): StoreProfileValue {
48+
return this.profiles.get(userId);
49+
}
50+
51+
/**
52+
* Synchronously get a profile from known users from the store cache.
53+
* Known user means that at least one shared room with the user exists.
54+
*
55+
* @param userId - User Id of the profile to fetch
56+
* @returns The profile, if cached by the store.
57+
* Null if the profile does not exist.
58+
* Undefined if the profile is not cached by the store.
59+
* In this case a profile can be fetched from the API via {@link fetchOnlyKnownProfile}.
60+
*/
61+
public getOnlyKnownProfile(userId: string): StoreProfileValue {
62+
return this.knownProfiles.get(userId);
63+
}
64+
65+
/**
66+
* Asynchronousely fetches a profile from the API.
67+
* Stores the result in the cache, so that next time {@link getProfile} returns this value.
68+
*
69+
* @param userId - User Id for which the profile should be fetched for
70+
* @returns The profile, if found.
71+
* Null if the profile does not exist or there was an error fetching it.
72+
*/
73+
public async fetchProfile(userId: string): Promise<IMatrixProfile | null> {
74+
const profile = await this.fetchProfileFromApi(userId);
75+
this.profiles.set(userId, profile);
76+
return profile;
77+
}
78+
79+
/**
80+
* Asynchronousely fetches a profile from a known user from the API.
81+
* Known user means that at least one shared room with the user exists.
82+
* Stores the result in the cache, so that next time {@link getOnlyKnownProfile} returns this value.
83+
*
84+
* @param userId - User Id for which the profile should be fetched for
85+
* @returns The profile, if found.
86+
* Undefined if the user is unknown.
87+
* Null if the profile does not exist or there was an error fetching it.
88+
*/
89+
public async fetchOnlyKnownProfile(userId: string): Promise<StoreProfileValue> {
90+
// Do not look up unknown users. The test for existence in knownProfiles is a performance optimisation.
91+
// If the user Id exists in knownProfiles we know them.
92+
if (!this.knownProfiles.has(userId) && !this.isUserIdKnown(userId)) return undefined;
93+
94+
const profile = await this.fetchProfileFromApi(userId);
95+
this.knownProfiles.set(userId, profile);
96+
return profile;
97+
}
98+
99+
/**
100+
* Looks up a user profile via API.
101+
*
102+
* @param userId - User Id for which the profile should be fetched for
103+
* @returns The profile information or null on errors
104+
*/
105+
private async fetchProfileFromApi(userId: string): Promise<IMatrixProfile | null> {
106+
try {
107+
return (await this.client.getProfileInfo(userId)) ?? null;
108+
} catch (e) {
109+
logger.warn(`Error retrieving profile for userId ${userId}`, e);
110+
}
111+
112+
return null;
113+
}
114+
115+
/**
116+
* Whether at least one shared room with the userId exists.
117+
*
118+
* @param userId
119+
* @returns true: at least one room shared with user identified by its Id, else false.
120+
*/
121+
private isUserIdKnown(userId: string): boolean {
122+
return this.client.getRooms().some((room) => {
123+
return !!room.getMember(userId);
124+
});
125+
}
126+
127+
/**
128+
* Simple cache invalidation if a room membership event is received and
129+
* at least one profile value differs from the cached one.
130+
*/
131+
private onRoomMembershipEvent = (event: MatrixEvent, member: RoomMember): void => {
132+
const profile = this.profiles.get(member.userId);
133+
134+
if (
135+
profile &&
136+
(profile.displayname !== member.rawDisplayName || profile.avatar_url !== member.getMxcAvatarUrl())
137+
) {
138+
this.profiles.delete(member.userId);
139+
}
140+
141+
const knownProfile = this.knownProfiles.get(member.userId);
142+
143+
if (
144+
knownProfile &&
145+
(knownProfile.displayname !== member.rawDisplayName || knownProfile.avatar_url !== member.getMxcAvatarUrl())
146+
) {
147+
this.knownProfiles.delete(member.userId);
148+
}
149+
};
150+
}

0 commit comments

Comments
 (0)