From 769d60c459e5b683cbae5714cf87c0779aa68484 Mon Sep 17 00:00:00 2001 From: nexy7574 Date: Fri, 28 Mar 2025 11:10:17 +0000 Subject: [PATCH] web/{timeline,composer}: render `m.room.tombstone` events (#608) --- web/src/api/types/mxtypes.ts | 5 ++ web/src/ui/composer/MessageComposer.css | 5 ++ web/src/ui/composer/MessageComposer.tsx | 44 +++++++++++++++++- .../ui/timeline/content/RoomTombstoneBody.tsx | 46 +++++++++++++++++++ web/src/ui/timeline/content/index.ts | 5 ++ 5 files changed, 103 insertions(+), 2 deletions(-) create mode 100644 web/src/ui/timeline/content/RoomTombstoneBody.tsx diff --git a/web/src/api/types/mxtypes.ts b/web/src/api/types/mxtypes.ts index 5089e7e..5242a8c 100644 --- a/web/src/api/types/mxtypes.ts +++ b/web/src/api/types/mxtypes.ts @@ -102,6 +102,11 @@ export interface RoomNameEventContent { name?: string } +export interface RoomCanonicalAliasEventContent { + alias?: RoomAlias | null + alt_aliases?: RoomAlias[] +} + export interface RoomTopicEventContent { topic?: string } diff --git a/web/src/ui/composer/MessageComposer.css b/web/src/ui/composer/MessageComposer.css index f6e83b9..2032301 100644 --- a/web/src/ui/composer/MessageComposer.css +++ b/web/src/ui/composer/MessageComposer.css @@ -14,6 +14,11 @@ div.message-composer { text-wrap: auto !important; } + &.tombstoned { + min-height: unset; + padding: .5rem; + } + > div.input-area { display: flex; align-items: center; diff --git a/web/src/ui/composer/MessageComposer.tsx b/web/src/ui/composer/MessageComposer.tsx index 34c8a86..e1c0039 100644 --- a/web/src/ui/composer/MessageComposer.tsx +++ b/web/src/ui/composer/MessageComposer.tsx @@ -13,9 +13,19 @@ // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -import React, { CSSProperties, use, useCallback, useEffect, useLayoutEffect, useReducer, useRef, useState } from "react" +import React, { + CSSProperties, + JSX, + use, + useCallback, + useEffect, + useLayoutEffect, + useReducer, + useRef, + useState, +} from "react" import { ScaleLoader } from "react-spinners" -import { useRoomEvent } from "@/api/statestore" +import { useRoomEvent, useRoomState } from "@/api/statestore" import type { EventID, MediaMessageEventContent, @@ -28,6 +38,7 @@ import type { import { PartialEmoji, emojiToMarkdown } from "@/util/emoji" import { isMobileDevice } from "@/util/ismobile.ts" import { escapeMarkdown } from "@/util/markdown.ts" +import { getServerName } from "@/util/validation.ts" import ClientContext from "../ClientContext.ts" import EmojiPicker from "../emojipicker/EmojiPicker.tsx" import GIFPicker from "../emojipicker/GIFPicker.tsx" @@ -576,6 +587,35 @@ const MessageComposer = () => { const inlineButtons = state.text === "" || window.innerWidth > 720 const showSendButton = canSend || window.innerWidth > 720 const disableClearMedia = editing && state.media?.msgtype === "m.sticker" + const tombstoneEvent = useRoomState(room, "m.room.tombstone", "") + if (tombstoneEvent !== null) { + const content = tombstoneEvent.content + const hasReplacement = content.replacement_room?.startsWith("!") + let link: JSX.Element | null = null + if (hasReplacement) { + const via = getServerName(tombstoneEvent.sender) + const handleNavigate = (e: React.MouseEvent) => { + e.preventDefault() + window.mainScreenContext.setActiveRoom(content.replacement_room, { + via: [via], + }) + } + const url = `matrix:roomid/${content.replacement_room.slice(1)}?via=${via}` + link = + Join the new one here + + } + let body = content.body + if (!body) { + body = hasReplacement ? "This room has been replaced." : "This room has been shut down." + } + if (!body.endsWith(".")) { + body += "." + } + return
+ {body} {link} +
+ } return <> {Autocompleter && autocomplete &&
. +import { JSX } from "react" +import { TombstoneEventContent } from "@/api/types" +import EventContentProps from "./props.ts" + +const RoomTombstoneBody = ({ event, sender }: EventContentProps) => { + const content = event.content as TombstoneEventContent + const end = content.body?.length > 0 ? ` with the message: ${content.body}` : "." + const onClick = (e: React.MouseEvent) => { + e.preventDefault() + window.mainScreenContext.setActiveRoom(content.replacement_room) + } + let description: JSX.Element + if (content.replacement_room?.length && content.replacement_room.startsWith("!")) { + description = ( + + replaced this room with  + + {content.replacement_room} + {end} + + ) + } else { + description = shut down this room{end} + } + return
+ {sender?.content.displayname ?? event.sender} {description} +
+} + +export default RoomTombstoneBody diff --git a/web/src/ui/timeline/content/index.ts b/web/src/ui/timeline/content/index.ts index c3931e9..92827fc 100644 --- a/web/src/ui/timeline/content/index.ts +++ b/web/src/ui/timeline/content/index.ts @@ -12,6 +12,7 @@ import PowerLevelBody from "./PowerLevelBody.tsx" import RedactedBody from "./RedactedBody.tsx" import RoomAvatarBody from "./RoomAvatarBody.tsx" import RoomNameBody from "./RoomNameBody.tsx" +import RoomTombstoneBody from "./RoomTombstoneBody.tsx" import TextMessageBody from "./TextMessageBody.tsx" import UnknownMessageBody from "./UnknownMessageBody.tsx" import EventContentProps from "./props.ts" @@ -31,6 +32,7 @@ export { default as RedactedBody } from "./RedactedBody.tsx" export { default as RoomAvatarBody } from "./RoomAvatarBody.tsx" export { default as RoomNameBody } from "./RoomNameBody.tsx" export { default as TextMessageBody } from "./TextMessageBody.tsx" +export { default as RoomTombstoneBody } from "./RoomTombstoneBody.tsx" export { default as UnknownMessageBody } from "./UnknownMessageBody.tsx" export type { default as EventContentProps } from "./props.ts" @@ -51,6 +53,8 @@ export function getBodyType(evt: MemDBEvent, forReply = false): React.FunctionCo return PinnedEventsBody case "m.room.power_levels": return PowerLevelBody + case "m.room.tombstone": + return RoomTombstoneBody } } else if (evt.state_key !== undefined) { // State events which must have a non-empty state key @@ -120,6 +124,7 @@ export function isSmallEvent(bodyType: React.FunctionComponent