From c496985ff304ac736eb04c546a8e1a7911d46e43 Mon Sep 17 00:00:00 2001
From: Andy Balaam <andy.balaam@matrix.org>
Date: Wed, 12 Apr 2023 16:26:45 +0100
Subject: [PATCH] Show a tile for an unloaded predecessor room if it has
 via_servers (#10483)

* Improve typing in constructor of RoomPermalinkCreator

* Provide via servers if present when navigating to predecessor room from Advanced Room Settings

* Show an error tile when the predecessor room is not found

* Test for MatrixToPermalinkConstructor.forRoom

* Test for MatrixToPermalinkConstructor.forEvent

* Display a tile for predecessor event if it contains via servers

* Fix missing case where event id is provided as well as via servers

* Refactor RoomPredecessor tests

* Return lost filterConsole to its home

* Comments for IState in AdvancedRoomSettingsTab

* Explain why we might render a tile even without prevRoom

* Guess the old room's via servers if they are not provided

* Fix TypeScript errors

* Adjust regular expression (hopefully) to avoid potential catastrophic backtracking

* Another attempt at avoiding super-liner regex performance

* Tests for guessServerNameFromRoomId and better implementation

* Further attempt to prevent backtracking

---------

Co-authored-by: Michael Telatynski <7t3chguy@gmail.com>
---
 .../views/messages/RoomPredecessorTile.tsx    | 115 ++++++++-
 .../tabs/room/AdvancedRoomSettingsTab.tsx     |  10 +
 src/i18n/strings/en_EN.json                   |   4 +-
 src/utils/permalinks/Permalinks.ts            |  26 +-
 .../messages/RoomPredecessorTile-test.tsx     | 233 +++++++++++++-----
 .../RoomPredecessorTile-test.tsx.snap         |   2 +-
 .../room/AdvancedRoomSettingsTab-test.tsx     |   3 +-
 .../MatrixToPermalinkConstructor-test.ts      |  16 ++
 8 files changed, 320 insertions(+), 89 deletions(-)

diff --git a/src/components/views/messages/RoomPredecessorTile.tsx b/src/components/views/messages/RoomPredecessorTile.tsx
index 5c47cda56f..bee0849fa4 100644
--- a/src/components/views/messages/RoomPredecessorTile.tsx
+++ b/src/components/views/messages/RoomPredecessorTile.tsx
@@ -18,6 +18,7 @@ limitations under the License.
 import React, { useCallback, useContext } from "react";
 import { logger } from "matrix-js-sdk/src/logger";
 import { MatrixEvent } from "matrix-js-sdk/src/models/event";
+import { Room } from "matrix-js-sdk/src/matrix";
 
 import dis from "../../../dispatcher/dispatcher";
 import { Action } from "../../../dispatcher/actions";
@@ -29,6 +30,7 @@ import { ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload";
 import RoomContext from "../../../contexts/RoomContext";
 import { useRoomState } from "../../../hooks/useRoomState";
 import SettingsStore from "../../../settings/SettingsStore";
+import MatrixToPermalinkConstructor from "../../../utils/permalinks/MatrixToPermalinkConstructor";
 
 interface IProps {
     /** The m.room.create MatrixEvent that this tile represents */
@@ -86,18 +88,56 @@ export const RoomPredecessorTile: React.FC<IProps> = ({ mxEvent, timestamp }) =>
     }
 
     const prevRoom = MatrixClientPeg.get().getRoom(predecessor.roomId);
-    if (!prevRoom) {
+
+    // We need either the previous room, or some servers to find it with.
+    // Otherwise, we must bail out here
+    if (!prevRoom && !predecessor.viaServers) {
         logger.warn(`Failed to find predecessor room with id ${predecessor.roomId}`);
-        return <></>;
-    }
-    const permalinkCreator = new RoomPermalinkCreator(prevRoom, predecessor.roomId);
-    permalinkCreator.load();
-    let predecessorPermalink: string;
-    if (predecessor.eventId) {
-        predecessorPermalink = permalinkCreator.forEvent(predecessor.eventId);
-    } else {
-        predecessorPermalink = permalinkCreator.forRoom();
+
+        const guessedLink = guessLinkForRoomId(predecessor.roomId);
+
+        return (
+            <EventTileBubble
+                className="mx_CreateEvent"
+                title={_t("This room is a continuation of another conversation.")}
+                timestamp={timestamp}
+            >
+                <div className="mx_EventTile_body">
+                    <span className="mx_EventTile_tileError">
+                        {!!guessedLink ? (
+                            <>
+                                {_t(
+                                    "Can't find the old version of this room (room ID: %(roomId)s), and we have not been " +
+                                        "provided with 'via_servers' to look for it. It's possible that guessing the " +
+                                        "server from the room ID will work. If you want to try, click this link:",
+                                    {
+                                        roomId: predecessor.roomId,
+                                    },
+                                )}
+                                <a href={guessedLink}>{guessedLink}</a>
+                            </>
+                        ) : (
+                            _t(
+                                "Can't find the old version of this room (room ID: %(roomId)s), and we have not been " +
+                                    "provided with 'via_servers' to look for it.",
+                                {
+                                    roomId: predecessor.roomId,
+                                },
+                            )
+                        )}
+                    </span>
+                </div>
+            </EventTileBubble>
+        );
     }
+    // Otherwise, we expect to be able to find this room either because it is
+    // already loaded, or because we have via_servers that we can use.
+    // So we go ahead with rendering the tile.
+
+    const predecessorPermalink = prevRoom
+        ? createLinkWithRoom(prevRoom, predecessor.roomId, predecessor.eventId)
+        : createLinkWithoutRoom(predecessor.roomId, predecessor.viaServers, predecessor.eventId);
+
     const link = (
         <a href={predecessorPermalink} onClick={onLinkClicked}>
             {_t("Click here to see older messages.")}
@@ -112,4 +152,59 @@ export const RoomPredecessorTile: React.FC<IProps> = ({ mxEvent, timestamp }) =>
             timestamp={timestamp}
         />
     );
+
+    function createLinkWithRoom(room: Room, roomId: string, eventId?: string): string {
+        const permalinkCreator = new RoomPermalinkCreator(room, roomId);
+        permalinkCreator.load();
+        if (eventId) {
+            return permalinkCreator.forEvent(eventId);
+        } else {
+            return permalinkCreator.forRoom();
+        }
+    }
+
+    function createLinkWithoutRoom(roomId: string, viaServers: string[], eventId?: string): string {
+        const matrixToPermalinkConstructor = new MatrixToPermalinkConstructor();
+        if (eventId) {
+            return matrixToPermalinkConstructor.forEvent(roomId, eventId, viaServers);
+        } else {
+            return matrixToPermalinkConstructor.forRoom(roomId, viaServers);
+        }
+    }
+
+    /**
+     * Guess the permalink for a room based on its room ID.
+     *
+     * The spec says that Room IDs are opaque [1] so this can only ever be a
+     * guess. There is no guarantee that this room exists on this server.
+     *
+     * [1] https://spec.matrix.org/v1.5/appendices/#room-ids-and-event-ids
+     */
+    function guessLinkForRoomId(roomId: string): string | null {
+        const serverName = guessServerNameFromRoomId(roomId);
+        if (serverName) {
+            return new MatrixToPermalinkConstructor().forRoom(roomId, [serverName]);
+        } else {
+            return null;
+        }
+    }
 };
+
+/**
+ * @internal Public for test only
+ *
+ * Guess the server name for a room based on its room ID.
+ *
+ * The spec says that Room IDs are opaque [1] so this can only ever be a
+ * guess. There is no guarantee that this room exists on this server.
+ *
+ * [1] https://spec.matrix.org/v1.5/appendices/#room-ids-and-event-ids
+ */
+export function guessServerNameFromRoomId(roomId: string): string | null {
+    const m = roomId.match(/^[^:]*:(.*)/);
+    if (m) {
+        return m[1];
+    } else {
+        return null;
+    }
+}
diff --git a/src/components/views/settings/tabs/room/AdvancedRoomSettingsTab.tsx b/src/components/views/settings/tabs/room/AdvancedRoomSettingsTab.tsx
index 34153819fc..869c51c949 100644
--- a/src/components/views/settings/tabs/room/AdvancedRoomSettingsTab.tsx
+++ b/src/components/views/settings/tabs/room/AdvancedRoomSettingsTab.tsx
@@ -42,8 +42,16 @@ interface IRecommendedVersion {
 interface IState {
     // This is eventually set to the value of room.getRecommendedVersion()
     upgradeRecommendation?: IRecommendedVersion;
+
+    /** The room ID of this room's predecessor, if it exists. */
     oldRoomId?: string;
+
+    /** The ID of tombstone event in this room's predecessor, if it exists. */
     oldEventId?: string;
+
+    /** The via servers to use to find this room's predecessor, if it exists. */
+    oldViaServers?: string[];
+
     upgraded?: boolean;
 }
 
@@ -65,6 +73,7 @@ export default class AdvancedRoomSettingsTab extends React.Component<IProps, ISt
             if (predecessor) {
                 additionalStateChanges.oldRoomId = predecessor.roomId;
                 additionalStateChanges.oldEventId = predecessor.eventId;
+                additionalStateChanges.oldViaServers = predecessor.viaServers;
             }
 
             this.setState({
@@ -88,6 +97,7 @@ export default class AdvancedRoomSettingsTab extends React.Component<IProps, ISt
             action: Action.ViewRoom,
             room_id: this.state.oldRoomId,
             event_id: this.state.oldEventId,
+            via_servers: this.state.oldViaServers,
             metricsTrigger: "WebPredecessorSettings",
             metricsViaKeyboard: e.type !== "click",
         });
diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json
index 363fe57f70..9d2ade976e 100644
--- a/src/i18n/strings/en_EN.json
+++ b/src/i18n/strings/en_EN.json
@@ -2458,8 +2458,10 @@
     "%(senderDisplayName)s changed the avatar for %(roomName)s": "%(senderDisplayName)s changed the avatar for %(roomName)s",
     "%(senderDisplayName)s removed the room avatar.": "%(senderDisplayName)s removed the room avatar.",
     "%(senderDisplayName)s changed the room avatar to <img/>": "%(senderDisplayName)s changed the room avatar to <img/>",
-    "Click here to see older messages.": "Click here to see older messages.",
     "This room is a continuation of another conversation.": "This room is a continuation of another conversation.",
+    "Can't find the old version of this room (room ID: %(roomId)s), and we have not been provided with 'via_servers' to look for it. It's possible that guessing the server from the room ID will work. If you want to try, click this link:": "Can't find the old version of this room (room ID: %(roomId)s), and we have not been provided with 'via_servers' to look for it. It's possible that guessing the server from the room ID will work. If you want to try, click this link:",
+    "Can't find the old version of this room (room ID: %(roomId)s), and we have not been provided with 'via_servers' to look for it.": "Can't find the old version of this room (room ID: %(roomId)s), and we have not been provided with 'via_servers' to look for it.",
+    "Click here to see older messages.": "Click here to see older messages.",
     "Add an Integration": "Add an Integration",
     "You are about to be taken to a third-party site so you can authenticate your account for use with %(integrationsUrl)s. Do you wish to continue?": "You are about to be taken to a third-party site so you can authenticate your account for use with %(integrationsUrl)s. Do you wish to continue?",
     "Edited at %(date)s": "Edited at %(date)s",
diff --git a/src/utils/permalinks/Permalinks.ts b/src/utils/permalinks/Permalinks.ts
index e42f40e8af..c4e15f3918 100644
--- a/src/utils/permalinks/Permalinks.ts
+++ b/src/utils/permalinks/Permalinks.ts
@@ -96,7 +96,7 @@ export class RoomPermalinkCreator {
     // Some of the tests done by this class are relatively expensive, so normally
     // throttled to not happen on every update. Pass false as the shouldThrottle
     // param to disable this behaviour, eg. for tests.
-    public constructor(private room: Room, roomId: string | null = null, shouldThrottle = true) {
+    public constructor(private room: Room | null, roomId: string | null = null, shouldThrottle = true) {
         this.roomId = room ? room.roomId : roomId!;
 
         if (!this.roomId) {
@@ -118,12 +118,12 @@ export class RoomPermalinkCreator {
 
     public start(): void {
         this.load();
-        this.room.currentState.on(RoomStateEvent.Update, this.onRoomStateUpdate);
+        this.room?.currentState.on(RoomStateEvent.Update, this.onRoomStateUpdate);
         this.started = true;
     }
 
     public stop(): void {
-        this.room.currentState.removeListener(RoomStateEvent.Update, this.onRoomStateUpdate);
+        this.room?.currentState.removeListener(RoomStateEvent.Update, this.onRoomStateUpdate);
         this.started = false;
     }
 
@@ -171,7 +171,7 @@ export class RoomPermalinkCreator {
     }
 
     private updateHighestPlUser(): void {
-        const plEvent = this.room.currentState.getStateEvents("m.room.power_levels", "");
+        const plEvent = this.room?.currentState.getStateEvents("m.room.power_levels", "");
         if (plEvent) {
             const content = plEvent.getContent();
             if (content) {
@@ -179,7 +179,7 @@ export class RoomPermalinkCreator {
                 if (users) {
                     const entries = Object.entries(users);
                     const allowedEntries = entries.filter(([userId]) => {
-                        const member = this.room.getMember(userId);
+                        const member = this.room?.getMember(userId);
                         if (!member || member.membership !== "join") {
                             return false;
                         }
@@ -213,8 +213,8 @@ export class RoomPermalinkCreator {
     private updateAllowedServers(): void {
         const bannedHostsRegexps: RegExp[] = [];
         let allowedHostsRegexps = [ANY_REGEX]; // default allow everyone
-        if (this.room.currentState) {
-            const aclEvent = this.room.currentState.getStateEvents(EventType.RoomServerAcl, "");
+        if (this.room?.currentState) {
+            const aclEvent = this.room?.currentState.getStateEvents(EventType.RoomServerAcl, "");
             if (aclEvent && aclEvent.getContent()) {
                 const getRegex = (hostname: string): RegExp =>
                     new RegExp("^" + utils.globToRegexp(hostname, false) + "$");
@@ -237,12 +237,14 @@ export class RoomPermalinkCreator {
 
     private updatePopulationMap(): void {
         const populationMap: { [server: string]: number } = {};
-        for (const member of this.room.getJoinedMembers()) {
-            const serverName = getServerName(member.userId);
-            if (!populationMap[serverName]) {
-                populationMap[serverName] = 0;
+        if (this.room) {
+            for (const member of this.room.getJoinedMembers()) {
+                const serverName = getServerName(member.userId);
+                if (!populationMap[serverName]) {
+                    populationMap[serverName] = 0;
+                }
+                populationMap[serverName]++;
             }
-            populationMap[serverName]++;
         }
         this.populationMap = populationMap;
     }
diff --git a/test/components/views/messages/RoomPredecessorTile-test.tsx b/test/components/views/messages/RoomPredecessorTile-test.tsx
index 61bf8e82c5..902eae1599 100644
--- a/test/components/views/messages/RoomPredecessorTile-test.tsx
+++ b/test/components/views/messages/RoomPredecessorTile-test.tsx
@@ -22,11 +22,14 @@ import { EventType, MatrixEvent, Room } from "matrix-js-sdk/src/matrix";
 
 import dis from "../../../../src/dispatcher/dispatcher";
 import SettingsStore from "../../../../src/settings/SettingsStore";
-import { RoomPredecessorTile } from "../../../../src/components/views/messages/RoomPredecessorTile";
+import {
+    guessServerNameFromRoomId,
+    RoomPredecessorTile,
+} from "../../../../src/components/views/messages/RoomPredecessorTile";
 import { stubClient, upsertRoomStateEvents } from "../../../test-utils/test-utils";
 import { Action } from "../../../../src/dispatcher/actions";
 import RoomContext from "../../../../src/contexts/RoomContext";
-import { getRoomContext } from "../../../test-utils";
+import { filterConsole, getRoomContext } from "../../../test-utils";
 import { MatrixClientPeg } from "../../../../src/MatrixClientPeg";
 
 jest.mock("../../../../src/dispatcher/dispatcher");
@@ -34,55 +37,56 @@ jest.mock("../../../../src/dispatcher/dispatcher");
 describe("<RoomPredecessorTile />", () => {
     const userId = "@alice:server.org";
     const roomId = "!room:server.org";
-    const createEvent = new MatrixEvent({
-        type: EventType.RoomCreate,
-        state_key: "",
-        sender: userId,
-        room_id: roomId,
-        content: {
-            predecessor: { room_id: "old_room_id", event_id: "tombstone_event_id" },
-        },
-        event_id: "$create",
-    });
-    const createEventWithoutPredecessor = new MatrixEvent({
-        type: EventType.RoomCreate,
-        state_key: "",
-        sender: userId,
-        room_id: roomId,
-        content: {},
-        event_id: "$create",
-    });
-    const predecessorEvent = new MatrixEvent({
-        type: EventType.RoomPredecessor,
-        state_key: "",
-        sender: userId,
-        room_id: roomId,
-        content: {
-            predecessor_room_id: "old_room_id_from_predecessor",
-        },
-        event_id: "$create",
-    });
-    const predecessorEventWithEventId = new MatrixEvent({
-        type: EventType.RoomPredecessor,
-        state_key: "",
-        sender: userId,
-        room_id: roomId,
-        content: {
-            predecessor_room_id: "old_room_id_from_predecessor",
-            last_known_event_id: "tombstone_event_id_from_predecessor",
-        },
-        event_id: "$create",
-    });
     stubClient();
     const client = mocked(MatrixClientPeg.get());
-    const roomJustCreate = new Room(roomId, client, userId);
-    upsertRoomStateEvents(roomJustCreate, [createEvent]);
-    const roomCreateAndPredecessor = new Room(roomId, client, userId);
-    upsertRoomStateEvents(roomCreateAndPredecessor, [createEvent, predecessorEvent]);
-    const roomCreateAndPredecessorWithEventId = new Room(roomId, client, userId);
-    upsertRoomStateEvents(roomCreateAndPredecessorWithEventId, [createEvent, predecessorEventWithEventId]);
-    const roomNoPredecessors = new Room(roomId, client, userId);
-    upsertRoomStateEvents(roomNoPredecessors, [createEventWithoutPredecessor]);
+
+    function makeRoom({
+        createEventHasPredecessor = false,
+        predecessorEventExists = false,
+        predecessorEventHasEventId = false,
+        predecessorEventHasViaServers = false,
+    }): Room {
+        const room = new Room(roomId, client, userId);
+
+        const createInfo = {
+            type: EventType.RoomCreate,
+            state_key: "",
+            sender: userId,
+            room_id: roomId,
+            content: {},
+            event_id: "$create",
+        };
+
+        if (createEventHasPredecessor) {
+            createInfo.content = {
+                predecessor: { room_id: "old_room_id", event_id: "$tombstone_event_id" },
+            };
+        }
+
+        const createEvent = new MatrixEvent(createInfo);
+        upsertRoomStateEvents(room, [createEvent]);
+
+        if (predecessorEventExists) {
+            const predecessorInfo = {
+                type: EventType.RoomPredecessor,
+                state_key: "",
+                sender: userId,
+                room_id: roomId,
+                content: {
+                    predecessor_room_id: "old_room_id_from_predecessor",
+                    last_known_event_id: predecessorEventHasEventId
+                        ? "$tombstone_event_id_from_predecessor"
+                        : undefined,
+                    via_servers: predecessorEventHasViaServers ? ["a.example.com", "b.example.com"] : undefined,
+                },
+                event_id: "$predecessor",
+            };
+
+            const predecessorEvent = new MatrixEvent(predecessorInfo);
+            upsertRoomStateEvents(room, [predecessorEvent]);
+        }
+        return room;
+    }
 
     beforeEach(() => {
         jest.clearAllMocks();
@@ -98,6 +102,10 @@ describe("<RoomPredecessorTile />", () => {
     });
 
     function renderTile(room: Room) {
+        // Find this room's create event (it should have one!)
+        const createEvent = room.currentState.getStateEvents("m.room.create")[0];
+        expect(createEvent).toBeTruthy();
+
         return render(
             <RoomContext.Provider value={getRoomContext(room, {})}>
                 <RoomPredecessorTile mxEvent={createEvent} />
@@ -106,25 +114,29 @@ describe("<RoomPredecessorTile />", () => {
     }
 
     it("Renders as expected", () => {
-        const roomCreate = renderTile(roomJustCreate);
+        const roomCreate = renderTile(makeRoom({ createEventHasPredecessor: true }));
         expect(roomCreate.asFragment()).toMatchSnapshot();
     });
 
     it("Links to the old version of the room", () => {
-        renderTile(roomJustCreate);
+        renderTile(makeRoom({ createEventHasPredecessor: true }));
         expect(screen.getByText("Click here to see older messages.")).toHaveAttribute(
             "href",
-            "https://matrix.to/#/old_room_id/tombstone_event_id",
+            "https://matrix.to/#/old_room_id/$tombstone_event_id",
         );
     });
 
-    it("Shows an empty div if there is no predecessor", () => {
-        renderTile(roomNoPredecessors);
-        expect(screen.queryByText("Click here to see older messages.", { exact: false })).toBeNull();
+    describe("(filtering warnings about no predecessor)", () => {
+        filterConsole("RoomPredecessorTile unexpectedly used in a room with no predecessor.");
+
+        it("Shows an empty div if there is no predecessor", () => {
+            renderTile(makeRoom({}));
+            expect(screen.queryByText("Click here to see older messages.", { exact: false })).toBeNull();
+        });
     });
 
     it("Opens the old room on click", async () => {
-        renderTile(roomJustCreate);
+        renderTile(makeRoom({ createEventHasPredecessor: true }));
         const link = screen.getByText("Click here to see older messages.");
 
         await act(() => userEvent.click(link));
@@ -132,7 +144,7 @@ describe("<RoomPredecessorTile />", () => {
         await waitFor(() =>
             expect(dis.dispatch).toHaveBeenCalledWith({
                 action: Action.ViewRoom,
-                event_id: "tombstone_event_id",
+                event_id: "$tombstone_event_id",
                 highlighted: true,
                 room_id: "old_room_id",
                 metricsTrigger: "Predecessor",
@@ -142,13 +154,26 @@ describe("<RoomPredecessorTile />", () => {
     });
 
     it("Ignores m.predecessor if labs flag is off", () => {
-        renderTile(roomCreateAndPredecessor);
+        renderTile(makeRoom({ createEventHasPredecessor: true, predecessorEventExists: true }));
         expect(screen.getByText("Click here to see older messages.")).toHaveAttribute(
             "href",
-            "https://matrix.to/#/old_room_id/tombstone_event_id",
+            "https://matrix.to/#/old_room_id/$tombstone_event_id",
         );
     });
 
+    describe("If the predecessor room is not found", () => {
+        filterConsole("Failed to find predecessor room with id old_room_id");
+
+        beforeEach(() => {
+            mocked(MatrixClientPeg.get().getRoom).mockReturnValue(null);
+        });
+
+        it("Shows an error if there are no via servers", () => {
+            renderTile(makeRoom({ createEventHasPredecessor: true, predecessorEventExists: true }));
+            expect(screen.getByText("Can't find the old version of this room", { exact: false })).toBeInTheDocument();
+        });
+    });
+
     describe("When feature_dynamic_room_predecessors = true", () => {
         beforeEach(() => {
             jest.spyOn(SettingsStore, "getValue").mockImplementation(
@@ -161,15 +186,15 @@ describe("<RoomPredecessorTile />", () => {
         });
 
         it("Uses the create event if there is no m.predecessor", () => {
-            renderTile(roomJustCreate);
+            renderTile(makeRoom({ createEventHasPredecessor: true }));
             expect(screen.getByText("Click here to see older messages.")).toHaveAttribute(
                 "href",
-                "https://matrix.to/#/old_room_id/tombstone_event_id",
+                "https://matrix.to/#/old_room_id/$tombstone_event_id",
             );
         });
 
         it("Uses m.predecessor when it's there", () => {
-            renderTile(roomCreateAndPredecessor);
+            renderTile(makeRoom({ createEventHasPredecessor: true, predecessorEventExists: true }));
             expect(screen.getByText("Click here to see older messages.")).toHaveAttribute(
                 "href",
                 "https://matrix.to/#/old_room_id_from_predecessor",
@@ -177,11 +202,91 @@ describe("<RoomPredecessorTile />", () => {
         });
 
         it("Links to the event in the room if event ID is provided", () => {
-            renderTile(roomCreateAndPredecessorWithEventId);
+            renderTile(
+                makeRoom({
+                    createEventHasPredecessor: true,
+                    predecessorEventExists: true,
+                    predecessorEventHasEventId: true,
+                }),
+            );
             expect(screen.getByText("Click here to see older messages.")).toHaveAttribute(
                 "href",
-                "https://matrix.to/#/old_room_id_from_predecessor/tombstone_event_id_from_predecessor",
+                "https://matrix.to/#/old_room_id_from_predecessor/$tombstone_event_id_from_predecessor",
             );
         });
+
+        describe("If the predecessor room is not found", () => {
+            filterConsole("Failed to find predecessor room with id old_room_id");
+
+            beforeEach(() => {
+                mocked(MatrixClientPeg.get().getRoom).mockReturnValue(null);
+            });
+
+            it("Shows an error if there are no via servers", () => {
+                renderTile(makeRoom({ createEventHasPredecessor: true, predecessorEventExists: true }));
+                expect(
+                    screen.getByText("Can't find the old version of this room", { exact: false }),
+                ).toBeInTheDocument();
+            });
+
+            it("Shows a tile if there are via servers", () => {
+                renderTile(
+                    makeRoom({
+                        createEventHasPredecessor: true,
+                        predecessorEventExists: true,
+                        predecessorEventHasViaServers: true,
+                    }),
+                );
+                expect(screen.getByText("Click here to see older messages.")).toHaveAttribute(
+                    "href",
+                    "https://matrix.to/#/old_room_id_from_predecessor?via=a.example.com&via=b.example.com",
+                );
+            });
+
+            it("Shows a tile linking to an event if there are via servers", () => {
+                renderTile(
+                    makeRoom({
+                        createEventHasPredecessor: true,
+                        predecessorEventExists: true,
+                        predecessorEventHasEventId: true,
+                        predecessorEventHasViaServers: true,
+                    }),
+                );
+                expect(screen.getByText("Click here to see older messages.")).toHaveAttribute(
+                    "href",
+                    "https://matrix.to/#/old_room_id_from_predecessor/$tombstone_event_id_from_predecessor?via=a.example.com&via=b.example.com",
+                );
+            });
+        });
+    });
+});
+
+describe("guessServerNameFromRoomId", () => {
+    it("Extracts the domain name from a standard room ID", () => {
+        expect(guessServerNameFromRoomId("!436456:example.com")).toEqual("example.com");
+    });
+
+    it("Extracts the domain name and port when included", () => {
+        expect(guessServerNameFromRoomId("!436456:example.com:8888")).toEqual("example.com:8888");
+    });
+
+    it("Handles an IPv4 address for server name", () => {
+        expect(guessServerNameFromRoomId("!436456:127.0.0.1")).toEqual("127.0.0.1");
+    });
+
+    it("Handles an IPv4 address and port", () => {
+        expect(guessServerNameFromRoomId("!436456:127.0.0.1:81")).toEqual("127.0.0.1:81");
+    });
+
+    it("Handles an IPv6 address for server name", () => {
+        expect(guessServerNameFromRoomId("!436456:::1")).toEqual("::1");
+    });
+
+    it("Handles an IPv6 address and port", () => {
+        expect(guessServerNameFromRoomId("!436456:::1:8080")).toEqual("::1:8080");
+    });
+
+    it("Returns null when the room ID contains no colon", () => {
+        expect(guessServerNameFromRoomId("!436456")).toBeNull();
     });
 });
diff --git a/test/components/views/messages/__snapshots__/RoomPredecessorTile-test.tsx.snap b/test/components/views/messages/__snapshots__/RoomPredecessorTile-test.tsx.snap
index 5e692de3fd..2b8670a752 100644
--- a/test/components/views/messages/__snapshots__/RoomPredecessorTile-test.tsx.snap
+++ b/test/components/views/messages/__snapshots__/RoomPredecessorTile-test.tsx.snap
@@ -14,7 +14,7 @@ exports[`<RoomPredecessorTile /> Renders as expected 1`] = `
       class="mx_EventTileBubble_subtitle"
     >
       <a
-        href="https://matrix.to/#/old_room_id/tombstone_event_id"
+        href="https://matrix.to/#/old_room_id/$tombstone_event_id"
       >
         Click here to see older messages.
       </a>
diff --git a/test/components/views/settings/tabs/room/AdvancedRoomSettingsTab-test.tsx b/test/components/views/settings/tabs/room/AdvancedRoomSettingsTab-test.tsx
index da1f3460e0..d00b1a8f7e 100644
--- a/test/components/views/settings/tabs/room/AdvancedRoomSettingsTab-test.tsx
+++ b/test/components/views/settings/tabs/room/AdvancedRoomSettingsTab-test.tsx
@@ -47,7 +47,7 @@ describe("AdvancedRoomSettingsTab", () => {
         mocked(dis.dispatch).mockReset();
         mocked(room.findPredecessor).mockImplementation((msc3946: boolean) =>
             msc3946
-                ? { roomId: "old_room_id_via_predecessor" }
+                ? { roomId: "old_room_id_via_predecessor", viaServers: ["one.example.com", "two.example.com"] }
                 : { roomId: "old_room_id", eventId: "tombstone_event_id" },
         );
     });
@@ -138,6 +138,7 @@ describe("AdvancedRoomSettingsTab", () => {
                 action: Action.ViewRoom,
                 event_id: undefined,
                 room_id: "old_room_id_via_predecessor",
+                via_servers: ["one.example.com", "two.example.com"],
                 metricsTrigger: "WebPredecessorSettings",
                 metricsViaKeyboard: false,
             });
diff --git a/test/utils/permalinks/MatrixToPermalinkConstructor-test.ts b/test/utils/permalinks/MatrixToPermalinkConstructor-test.ts
index e875011413..27cd4a993a 100644
--- a/test/utils/permalinks/MatrixToPermalinkConstructor-test.ts
+++ b/test/utils/permalinks/MatrixToPermalinkConstructor-test.ts
@@ -41,4 +41,20 @@ describe("MatrixToPermalinkConstructor", () => {
             );
         });
     });
+
+    describe("forRoom", () => {
+        it("constructs a link given a room ID and via servers", () => {
+            expect(peramlinkConstructor.forRoom("!myroom:example.com", ["one.example.com", "two.example.com"])).toEqual(
+                "https://matrix.to/#/!myroom:example.com?via=one.example.com&via=two.example.com",
+            );
+        });
+    });
+
+    describe("forEvent", () => {
+        it("constructs a link given an event ID, room ID and via servers", () => {
+            expect(
+                peramlinkConstructor.forEvent("!myroom:example.com", "$event4", ["one.example.com", "two.example.com"]),
+            ).toEqual("https://matrix.to/#/!myroom:example.com/$event4?via=one.example.com&via=two.example.com");
+        });
+    });
 });