diff --git a/src/app/features/app-chat/AppChatRoomList.tsx b/src/app/features/app-chat/AppChatRoomList.tsx index bdcf4ac2..589195ff 100644 --- a/src/app/features/app-chat/AppChatRoomList.tsx +++ b/src/app/features/app-chat/AppChatRoomList.tsx @@ -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'; @@ -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; @@ -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; @@ -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; @@ -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((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(); + 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'], @@ -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) { @@ -220,14 +281,21 @@ export function AppChatRoomList({ selectedRoomId }: AppChatRoomListProps) { {rooms.length > 0 && (
Your rooms - {rooms.map((room) => ( - - ))} + {rooms.map(({ entry, room, unjoined }) => + room ? ( + + ) : ( + + ) + )}
)} {unjoinedRooms.length > 0 && ( diff --git a/src/app/features/app-chat/chatRooms.test.ts b/src/app/features/app-chat/chatRooms.test.ts index 4285d1d1..f9988d0a 100644 --- a/src/app/features/app-chat/chatRooms.test.ts +++ b/src/app/features/app-chat/chatRooms.test.ts @@ -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', () => { @@ -98,3 +98,70 @@ describe('getUnjoinedChatRooms', () => { ]); }); }); + +describe('resolveReplacedRooms', () => { + const makeResolver = (replacements: Record, 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'] }]); + }); +}); diff --git a/src/app/features/app-chat/chatRooms.ts b/src/app/features/app-chat/chatRooms.ts index d6302b21..60f23480 100644 --- a/src/app/features/app-chat/chatRooms.ts +++ b/src/app/features/app-chat/chatRooms.ts @@ -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(); + + 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()); +};