Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/fix-sliding-sync-member-profiles.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
default: patch
---

Fix display names for read receipts, event relations, and state events when using sliding sync with the member drawer closed.
21 changes: 17 additions & 4 deletions src/app/components/user-profile/UserRoomProfile.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { Box, Button, color, config, Menu, MenuItem, Scroll, Text, toRem } from 'folds';
import type { CSSProperties, SyntheticEvent } from 'react';
import { useCallback, useMemo, useState } from 'react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useAtomValue } from 'jotai';
import type { Opts as LinkifyOpts } from 'linkifyjs';
Expand Down Expand Up @@ -59,6 +59,7 @@ import { PowerChip } from './PowerChip';
import { IgnoredUserAlert, MutualRoomsChip, OptionsChip, ServerChip, ShareChip } from './UserChips';
import { UserHero, UserHeroName } from './UserHero';
import { KnownMembership } from '$types/matrix-sdk';
import { hydrateRoomMember } from '$client/roomMemberHydration';
import * as css from './styles.css';
import * as prefix from '$unstable/prefixes';

Expand Down Expand Up @@ -436,9 +437,6 @@ export function UserRoomProfile({ userId, initialProfile }: Readonly<UserRoomPro
const server = getMxIdServer(userId);
const nicknames = useAtomValue(nicknamesAtom);
const displayName = getMemberDisplayName(room, userId, nicknames);
const avatarMxc = getMemberAvatarMxc(room, userId);
const avatarUrl = (avatarMxc && mxcUrlToHttp(mx, avatarMxc, useAuthentication)) ?? undefined;

const presence = useUserPresence(userId);

const fetchedProfile = useUserProfile(userId, room);
Expand All @@ -447,6 +445,21 @@ export function UserRoomProfile({ userId, initialProfile }: Readonly<UserRoomPro
? fetchedProfile
: (initialProfile as UserProfile) || fetchedProfile;

const [, refreshMemberProfile] = useState(0);
useEffect(() => {
if (room.getMember(userId)) return undefined;
let disposed = false;
void hydrateRoomMember(mx, room.roomId, userId).then(() => {
if (!disposed) refreshMemberProfile((version) => version + 1);
});
return () => {
disposed = true;
};
}, [mx, room, userId]);

const avatarMxc = getMemberAvatarMxc(room, userId) ?? extendedProfile.avatarUrl;
const avatarUrl = (avatarMxc && mxcUrlToHttp(mx, avatarMxc, useAuthentication)) ?? undefined;

const parsedBanner =
typeof extendedProfile.bannerUrl === 'string'
? extendedProfile.bannerUrl.replaceAll(/^"|"$/g, '')
Expand Down
4 changes: 4 additions & 0 deletions src/app/hooks/timeline/useTimelineSync.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,10 @@ vi.mock('$utils/timeline', async (importOriginal) => {
};
});

vi.mock('$utils/notifications', () => ({
markAsRead: vi.fn<() => Promise<void>>().mockResolvedValue(undefined),
}));

type FakeTimeline = {
getEvents: () => unknown[];
getNeighbouringTimeline: () => undefined;
Expand Down
8 changes: 7 additions & 1 deletion src/client/initMatrix.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import type { SlidingSyncDiagnostics } from './slidingSync';
import { scopeEphemeralExtensions, SlidingSyncManager } from './slidingSync';
import { PresenceSyncManager } from './presenceSync';
import { SlidingSyncSidebarCache } from './slidingSyncSidebarCache';
import { hydrateRoomMember } from './roomMemberHydration';

const log = createLogger('initMatrix');
const debugLog = createDebugLogger('initMatrix');
Expand Down Expand Up @@ -171,7 +172,12 @@ function installStartupFetchRoomEventPatch(

mxWritable.fetchRoomEvent = (roomId: string, eventId: string) => {
if (slidingSyncManager.isRoomActive(roomId)) {
return origFetchRoomEvent(roomId, eventId);
return origFetchRoomEvent(roomId, eventId).then((event) => {
if (typeof event.sender === 'string') {
void hydrateRoomMember(mx, roomId, event.sender);
}
return event;
});
}
const cachedEvent = mx.getRoom(roomId)?.findEventById(eventId);
const payload: FetchRoomEventResult = cachedEvent?.event ?? {
Expand Down
54 changes: 54 additions & 0 deletions src/client/roomMemberHydration.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import type { MatrixClient, Room } from '$types/matrix-sdk';
import { EventType, MatrixEvent } from '$types/matrix-sdk';

const inFlight = new WeakMap<MatrixClient, Map<string, Promise<void>>>();

export const hydrateRoomMember = (
mx: MatrixClient,
roomId: string,
userId: string
): Promise<void> => {
const room = mx.getRoom(roomId);
if (!room || room.getMember(userId)) return Promise.resolve();

const key = `${roomId}\u0000${userId}`;
const pending = inFlight.get(mx) ?? new Map<string, Promise<void>>();
inFlight.set(mx, pending);
const existing = pending.get(key);
if (existing) return existing;

const request = mx
.getStateEvent(roomId, EventType.RoomMember, userId)
.then((content) => {
const currentRoom = mx.getRoom(roomId);
if (!currentRoom || currentRoom.getMember(userId)) return;
currentRoom.currentState.setStateEvents([
new MatrixEvent({
type: EventType.RoomMember,
state_key: userId,
room_id: roomId,
sender: userId,
content,
}),
]);
})
.catch(() => undefined)
.finally(() => pending.delete(key));

pending.set(key, request);
return request;
};

export const hydrateRoomMembers = (
mx: MatrixClient,
roomId: string,
userIds: Iterable<string>
): Promise<void[]> =>
Promise.all(
[...new Set(userIds)]
.filter((userId) => userId.startsWith('@'))
.map((userId) => hydrateRoomMember(mx, roomId, userId))
);

export const getRoomMemberAvatarMxc = (room: Room, userId: string): string | undefined =>
room.getMember(userId)?.getMxcAvatarUrl();
3 changes: 3 additions & 0 deletions src/client/slidingSync.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,7 @@ describe('SlidingSyncManager initial request', () => {
const updates = lists.get('updates');
const defaultSubscription = mocks.slidingSyncConstructorArgs?.[2] as {
timeline_limit: number;
required_state: string[][];
};

expect(joined?.ranges).toEqual([[0, 29]]);
Expand All @@ -126,6 +127,8 @@ describe('SlidingSyncManager initial request', () => {
filters: { is_invite: false },
});
expect(defaultSubscription.timeline_limit).toBe(30);
expect(defaultSubscription.required_state).toContainEqual([EventType.RoomMember, '$LAZY']);
expect(defaultSubscription.required_state).not.toContainEqual([EventType.RoomMember, '*']);
expect(mocks.slidingSyncConstructorArgs?.[4]).toBe(45000);
});

Expand Down
38 changes: 38 additions & 0 deletions src/client/slidingSync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import { createDebugLogger } from '$utils/debugLogger';
import { CustomStateEvent } from '$types/matrix/room';
import * as Sentry from '@sentry/react';
import { SlidingSyncSidebarCache } from './slidingSyncSidebarCache';
import { hydrateRoomMembers } from './roomMemberHydration';

const log = createLogger('slidingSync');
const debugLog = createDebugLogger('slidingSync');
Expand Down Expand Up @@ -381,6 +382,7 @@ export class SlidingSyncManager {
if (err || !resp || state !== SlidingSyncState.Complete) return;

this.recordServerMembershipRooms(resp);
this.hydrateReferencedMembers(resp);

this.roomDataAwaitingSyncCompletion.forEach((roomId) =>
this.notifyRoomSubscriptionStatus(roomId, false)
Expand Down Expand Up @@ -604,6 +606,42 @@ export class SlidingSyncManager {
});
}

private hydrateReferencedMembers(response: MSC3575SlidingSyncResponse): void {
const userIdsByRoom = new Map<string, Set<string>>();
const add = (roomId: string, userId: unknown) => {
if (!this.activeRoomSubscriptions.has(roomId) || typeof userId !== 'string') return;
const userIds = userIdsByRoom.get(roomId) ?? new Set<string>();
userIds.add(userId);
userIdsByRoom.set(roomId, userIds);
};

Object.entries(response.rooms ?? {}).forEach(([roomId, roomData]) => {
[...(roomData.timeline ?? []), ...(roomData.required_state ?? [])].forEach((event) => {
add(roomId, event.sender);
});
});

const receipts = response.extensions?.receipts as
| {
rooms?: Record<
string,
{ content?: Record<string, Record<string, Record<string, unknown>>> }
>;
}
| undefined;
Object.entries(receipts?.rooms ?? {}).forEach(([roomId, event]) => {
Object.values(event.content ?? {}).forEach((receiptTypes) => {
Object.values(receiptTypes).forEach((users) => {
Object.keys(users).forEach((userId) => add(roomId, userId));
});
});
});

userIdsByRoom.forEach((userIds, roomId) => {
void hydrateRoomMembers(this.mx, roomId, userIds);
});
}

private reconcileSidebarCacheMembership(): void {
if (this.sidebarCacheReconciled || this.sidebarCacheReconciliationQueued) return;
this.sidebarCacheReconciliationQueued = true;
Expand Down
Loading