From 549151b237a148a701f9e6a03c22706865de2cca Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 15:20:16 +0000 Subject: [PATCH] Show replacement rooms instead of upgraded rooms in the /app chat list When a room in a community has been replaced (m.room.tombstone), the /app chat list now follows the replacement chain and shows the newest room in place of the old one, dropping duplicates when both are listed. If the new room hasn't been joined yet it is shown with a Join button, and replaced rooms are hidden from the "More rooms" section. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01HycoSvTLYyVB9Lojzbqxjs --- src/app/features/app-chat/AppChatRoomList.tsx | 112 ++++++++++++++---- src/app/features/app-chat/chatRooms.test.ts | 69 ++++++++++- src/app/features/app-chat/chatRooms.ts | 71 +++++++++++ 3 files changed, 229 insertions(+), 23 deletions(-) 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()); +};