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
112 changes: 90 additions & 22 deletions src/app/features/app-chat/AppChatRoomList.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import React, { useEffect, useMemo, useState } from 'react';
import { Link } from 'react-router-dom';
import { useAtomValue } from 'jotai';
import { MatrixError, Room, RoomEvent, RoomEventHandlerMap } from 'matrix-js-sdk';
import { MatrixClient, MatrixError, Room, RoomEvent, RoomEventHandlerMap } from 'matrix-js-sdk';
import { useQuery } from '@tanstack/react-query';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
Expand All @@ -12,15 +12,23 @@ import { useRoomUnread } from '../../state/hooks/unread';
import { roomToParentsAtom } from '../../state/room/roomToParents';
import { roomToUnreadAtom } from '../../state/room/roomToUnread';
import { allRoomsAtom } from '../../state/room-list/roomList';
import { getMemberDisplayName, getRoomAvatarUrl } from '../../utils/room';
import { mxcUrlToHttp } from '../../utils/matrix';
import { getMemberDisplayName, getRoomAvatarUrl, getStateEvent } from '../../utils/room';
import { getMxIdServer, mxcUrlToHttp } from '../../utils/matrix';
import { StateEvent } from '../../../types/matrix/room';
import { factoryRoomIdByActivity } from '../../utils/sort';
import { nameInitials } from '../../utils/common';
import { relativeTime } from '../../utils/time';
import { useCommunity } from '../../pages/app-shell/CommunityContext';
import { AppEmptyState } from '../../pages/app-shell/AppEmptyState';
import { getAppCommunityChatRoomPath } from '../../pages/pathUtils';
import { UnjoinedChatRoom, getMessagePreview, getUnjoinedChatRooms } from './chatRooms';
import {
ResolvedChatRoom,
RoomReplacement,
UnjoinedChatRoom,
getMessagePreview,
getUnjoinedChatRooms,
resolveReplacedRooms,
} from './chatRooms';
import * as css from './AppChat.css';

const HIERARCHY_LIMIT = 100;
Expand Down Expand Up @@ -54,6 +62,16 @@ const useRoomsActivityTick = (roomIds: string[]): number => {
return tick;
};

/** The room that replaced `roomId`, read from its m.room.tombstone state (if it's in the store). */
const getRoomReplacement = (mx: MatrixClient, roomId: string): RoomReplacement | undefined => {
const room = mx.getRoom(roomId);
const tombstone = room ? getStateEvent(room, StateEvent.RoomTombstone) : undefined;
const replacementRoomId: unknown = tombstone?.getContent().replacement_room;
if (!tombstone || typeof replacementRoomId !== 'string' || !replacementRoomId) return undefined;
const server = getMxIdServer(tombstone.getSender() ?? '');
return { roomId: replacementRoomId, via: server ? [server] : [] };
};

type ChatRoomItemProps = {
room: Room;
communityId: string;
Expand Down Expand Up @@ -162,6 +180,10 @@ function UnjoinedChatRoomItem({ room }: UnjoinedChatRoomItemProps) {
);
}

type ChatListItem =
| { entry: ResolvedChatRoom; room: Room; unjoined?: undefined }
| { entry: ResolvedChatRoom; room?: undefined; unjoined: UnjoinedChatRoom };

type AppChatRoomListProps = {
/** Room open next to the list in the desktop split view. */
selectedRoomId?: string;
Expand All @@ -180,16 +202,53 @@ export function AppChatRoomList({ selectedRoomId }: AppChatRoomListProps) {
useRecursiveChildScopeFactory(mx, roomToParents)
);
const activityTick = useRoomsActivityTick(roomIds);
const rooms = useMemo(
// Replaced (upgraded) rooms are swapped for the room that replaced them. A
// tombstone arrives as a live timeline event, so activityTick also covers it.
const resolvedRooms = useMemo(
() =>
[...roomIds]
.sort(factoryRoomIdByActivity(mx))
.map((roomId) => mx.getRoom(roomId))
.filter((room): room is Room => !!room),
resolveReplacedRooms(
[...roomIds].sort(factoryRoomIdByActivity(mx)),
(roomId) => getRoomReplacement(mx, roomId),
(roomId) => allRooms.includes(roomId)
),
// activityTick re-sorts the list when new messages arrive.
// eslint-disable-next-line react-hooks/exhaustive-deps
[mx, roomIds, activityTick]
[mx, roomIds, allRooms, activityTick]
);
const rooms = useMemo(
() =>
resolvedRooms.reduce<ChatListItem[]>((items, entry) => {
if (entry.kind === 'joined') {
const room = mx.getRoom(entry.roomId);
if (room) items.push({ entry, room });
return items;
}
const latestRoom = mx.getRoom(entry.latestJoinedId);
if (latestRoom) {
items.push({
entry,
unjoined: {
roomId: entry.roomId,
name: latestRoom.name,
topic: 'This room has been replaced. Join the new room to continue.',
avatarUrl: latestRoom.getMxcAvatarUrl() ?? undefined,
memberCount: 0,
via: entry.via,
},
});
}
return items;
}, []),
[mx, resolvedRooms]
);
const shownRoomIds = useMemo(() => {
const ids = new Set<string>();
resolvedRooms.forEach((entry) => {
ids.add(entry.roomId);
entry.predecessorIds.forEach((id) => ids.add(id));
});
return ids;
}, [resolvedRooms]);

const { data: hierarchy } = useQuery({
queryKey: [community.roomId, 'app-chat-hierarchy'],
Expand All @@ -199,11 +258,13 @@ export function AppChatRoomList({ selectedRoomId }: AppChatRoomListProps) {
const unjoinedRooms = useMemo(
() =>
hierarchy
? getUnjoinedChatRooms(community.roomId, hierarchy.rooms, (roomId) =>
allRooms.includes(roomId)
)
? getUnjoinedChatRooms(
community.roomId,
hierarchy.rooms,
(roomId) => allRooms.includes(roomId) || shownRoomIds.has(roomId)
).filter((room) => !getRoomReplacement(mx, room.roomId))
: [],
[hierarchy, community.roomId, allRooms]
[mx, hierarchy, community.roomId, allRooms, shownRoomIds]
);

if (rooms.length === 0 && unjoinedRooms.length === 0) {
Expand All @@ -220,14 +281,21 @@ export function AppChatRoomList({ selectedRoomId }: AppChatRoomListProps) {
{rooms.length > 0 && (
<section className={css.Section}>
<span className={css.SectionTitle}>Your rooms</span>
{rooms.map((room) => (
<ChatRoomItem
key={room.roomId}
room={room}
communityId={community.roomId}
selected={room.roomId === selectedRoomId}
/>
))}
{rooms.map(({ entry, room, unjoined }) =>
room ? (
<ChatRoomItem
key={entry.roomId}
room={room}
communityId={community.roomId}
selected={
!!selectedRoomId &&
(entry.roomId === selectedRoomId || entry.predecessorIds.includes(selectedRoomId))
}
/>
) : (
<UnjoinedChatRoomItem key={entry.roomId} room={unjoined} />
)
)}
</section>
)}
{unjoinedRooms.length > 0 && (
Expand Down
69 changes: 68 additions & 1 deletion src/app/features/app-chat/chatRooms.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest';
import { IHierarchyRoom } from 'matrix-js-sdk/lib/@types/spaces';
import { getMessagePreview, getUnjoinedChatRooms } from './chatRooms';
import { getMessagePreview, getUnjoinedChatRooms, resolveReplacedRooms } from './chatRooms';

describe('getMessagePreview', () => {
it('returns the body of a text message', () => {
Expand Down Expand Up @@ -98,3 +98,70 @@ describe('getUnjoinedChatRooms', () => {
]);
});
});

describe('resolveReplacedRooms', () => {
const makeResolver = (replacements: Record<string, string>, joined: string[]) =>
[
(roomId: string) =>
replacements[roomId] ? { roomId: replacements[roomId], via: ['example.org'] } : undefined,
(roomId: string) => joined.includes(roomId),
] as const;

it('keeps rooms that were never replaced', () => {
expect(resolveReplacedRooms(['!a', '!b'], ...makeResolver({}, ['!a', '!b']))).toEqual([
{ kind: 'joined', roomId: '!a', predecessorIds: [] },
{ kind: 'joined', roomId: '!b', predecessorIds: [] },
]);
});

it('swaps a replaced room for its joined replacement in place', () => {
expect(
resolveReplacedRooms(
['!x', '!old', '!y'],
...makeResolver({ '!old': '!new' }, ['!x', '!old', '!y', '!new'])
)
).toEqual([
{ kind: 'joined', roomId: '!x', predecessorIds: [] },
{ kind: 'joined', roomId: '!new', predecessorIds: ['!old'] },
{ kind: 'joined', roomId: '!y', predecessorIds: [] },
]);
});

it('drops the old room when its replacement is also listed', () => {
expect(
resolveReplacedRooms(['!new', '!old'], ...makeResolver({ '!old': '!new' }, ['!old', '!new']))
).toEqual([{ kind: 'joined', roomId: '!new', predecessorIds: ['!old'] }]);
expect(
resolveReplacedRooms(['!old', '!new'], ...makeResolver({ '!old': '!new' }, ['!old', '!new']))
).toEqual([{ kind: 'joined', roomId: '!new', predecessorIds: ['!old'] }]);
});

it('follows a chain of upgrades to the newest room', () => {
expect(
resolveReplacedRooms(
['!v1'],
...makeResolver({ '!v1': '!v2', '!v2': '!v3' }, ['!v1', '!v2', '!v3'])
)
).toEqual([{ kind: 'joined', roomId: '!v3', predecessorIds: ['!v1', '!v2'] }]);
});

it('offers an unjoined replacement instead of the old room', () => {
expect(
resolveReplacedRooms(['!v1'], ...makeResolver({ '!v1': '!v2', '!v2': '!v3' }, ['!v1', '!v2']))
).toEqual([
{
kind: 'unjoined',
roomId: '!v3',
via: ['example.org'],
latestJoinedId: '!v2',
predecessorIds: ['!v1', '!v2'],
},
]);
});

it('stops on replacement cycles', () => {
expect(
resolveReplacedRooms(['!a'], ...makeResolver({ '!a': '!b', '!b': '!a' }, ['!a', '!b']))
).toEqual([{ kind: 'joined', roomId: '!b', predecessorIds: ['!a'] }]);
});
});
71 changes: 71 additions & 0 deletions src/app/features/app-chat/chatRooms.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,3 +83,74 @@ export const getUnjoinedChatRooms = (
via: viaMap.get(room.room_id) ?? [],
}));
};

export type RoomReplacement = {
/** The room that replaced this one (m.room.tombstone `replacement_room`). */
roomId: string;
/** Servers to try when joining the replacement room. */
via: string[];
};

export type ResolvedChatRoom =
| {
/** A joined room at the end of its replacement chain. */
kind: 'joined';
roomId: string;
/** Rooms in the list that were replaced by this one (oldest first). */
predecessorIds: string[];
}
| {
/** A replacement room the user hasn't joined yet. */
kind: 'unjoined';
roomId: string;
via: string[];
/** The newest room in the chain the user is still in; used for its name and avatar. */
latestJoinedId: string;
predecessorIds: string[];
};

/**
* Swaps every replaced (tombstoned) room in `roomIds` for the newest room in
* its replacement chain, keeping the original order and dropping duplicates
* (e.g. when both an old room and its replacement are in the list). If the
* newest room hasn't been joined yet it is returned as `unjoined` so the
* list can offer to join it instead of showing the dead room.
*/
export const resolveReplacedRooms = (
roomIds: string[],
getReplacement: (roomId: string) => RoomReplacement | undefined,
isJoined: (roomId: string) => boolean
): ResolvedChatRoom[] => {
const resolved = new Map<string, ResolvedChatRoom>();

roomIds.forEach((startId) => {
const chain = [startId];
let via: string[] = [];
let latestJoinedId = startId;
let replacement = getReplacement(startId);
while (replacement && !chain.includes(replacement.roomId)) {
chain.push(replacement.roomId);
via = replacement.via;
if (isJoined(replacement.roomId)) latestJoinedId = replacement.roomId;
replacement = getReplacement(replacement.roomId);
}

const roomId = chain[chain.length - 1];
const predecessorIds = chain.slice(0, -1);
const existing = resolved.get(roomId);
if (existing) {
predecessorIds.forEach((id) => {
if (!existing.predecessorIds.includes(id)) existing.predecessorIds.push(id);
});
return;
}
resolved.set(
roomId,
isJoined(roomId)
? { kind: 'joined', roomId, predecessorIds }
: { kind: 'unjoined', roomId, via, latestJoinedId, predecessorIds }
);
});

return Array.from(resolved.values());
};
Loading