diff --git a/web/src/api/types/mxtypes.ts b/web/src/api/types/mxtypes.ts
index 0e29f29..97f1893 100644
--- a/web/src/api/types/mxtypes.ts
+++ b/web/src/api/types/mxtypes.ts
@@ -73,6 +73,18 @@ export interface MemberEventContent extends UserProfile {
reason?: string
}
+export interface RoomAvatarEventContent {
+ url?: ContentURI
+}
+
+export interface RoomNameEventContent {
+ name?: string
+}
+
+export interface RoomTopicEventContent {
+ topic?: string
+}
+
export interface ACLEventContent {
allow?: string[]
allow_ip_literals?: boolean
diff --git a/web/src/ui/timeline/content/RoomAvatarBody.tsx b/web/src/ui/timeline/content/RoomAvatarBody.tsx
new file mode 100644
index 0000000..d996bba
--- /dev/null
+++ b/web/src/ui/timeline/content/RoomAvatarBody.tsx
@@ -0,0 +1,52 @@
+// gomuks - A Matrix client written in Go.
+// Copyright (C) 2024 Tulir Asokan
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+import { JSX, use } from "react"
+import { getRoomAvatarURL } from "@/api/media.ts"
+import { ContentURI, RoomAvatarEventContent } from "@/api/types"
+import { ensureString } from "@/util/validation.ts"
+import { LightboxContext } from "../../modal/Lightbox.tsx"
+import EventContentProps from "./props.ts"
+
+const RoomAvatarBody = ({ event, sender, room }: EventContentProps) => {
+ const content = event.content as RoomAvatarEventContent
+ const prevContent = event.unsigned.prev_content as RoomAvatarEventContent | undefined
+ let changeDescription: JSX.Element | string
+ const oldURL = ensureString(prevContent?.url)
+ const newURL = ensureString(content.url)
+ const openLightbox = use(LightboxContext)!
+ const makeAvatar = (url: ContentURI) =>
+ if (oldURL === newURL) {
+ changeDescription = "sent a room avatar event with no change"
+ } else if (oldURL && newURL) {
+ changeDescription = <>changed the room avatar from {makeAvatar(oldURL)} to {makeAvatar(newURL)}>
+ } else if (!oldURL) {
+ changeDescription = <>set the room avatar to {makeAvatar(newURL)}>
+ } else {
+ changeDescription = "removed the room avatar"
+ }
+ return
+ {sender?.content.displayname ?? event.sender} {changeDescription}
+
+}
+
+export default RoomAvatarBody
diff --git a/web/src/ui/timeline/content/RoomNameBody.tsx b/web/src/ui/timeline/content/RoomNameBody.tsx
new file mode 100644
index 0000000..721d676
--- /dev/null
+++ b/web/src/ui/timeline/content/RoomNameBody.tsx
@@ -0,0 +1,45 @@
+// gomuks - A Matrix client written in Go.
+// Copyright (C) 2024 Tulir Asokan
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+import { JSX } from "react"
+import { RoomNameEventContent } from "@/api/types"
+import { ensureString } from "@/util/validation.ts"
+import EventContentProps from "./props.ts"
+
+function bidiIsolate(str: string): JSX.Element {
+ return {str}
+}
+
+const RoomNameBody = ({ event, sender }: EventContentProps) => {
+ const content = event.content as RoomNameEventContent
+ const prevContent = event.unsigned.prev_content as RoomNameEventContent | undefined
+ let changeDescription: JSX.Element | string
+ const oldName = ensureString(prevContent?.name)
+ const newName = ensureString(content.name)
+ if (oldName === newName) {
+ changeDescription = "sent a room name event with no change"
+ } else if (oldName && newName) {
+ changeDescription = <>changed the room name from {bidiIsolate(oldName)} to {bidiIsolate(newName)}>
+ } else if (!oldName) {
+ changeDescription = <>set the room name to {bidiIsolate(newName)}>
+ } else {
+ changeDescription = "removed the room name"
+ }
+ return
+ {sender?.content.displayname ?? event.sender} {changeDescription}
+
+}
+
+export default RoomNameBody
diff --git a/web/src/ui/timeline/content/index.css b/web/src/ui/timeline/content/index.css
index 15c5a83..ac4506e 100644
--- a/web/src/ui/timeline/content/index.css
+++ b/web/src/ui/timeline/content/index.css
@@ -44,6 +44,12 @@ div.member-body {
}
}
+div.room-avatar-body {
+ display: flex;
+ align-items: center;
+ gap: .25rem;
+}
+
div.message-text {
&.plaintext-body {
white-space: pre-wrap;
diff --git a/web/src/ui/timeline/content/index.ts b/web/src/ui/timeline/content/index.ts
index 05927ea..b2f666a 100644
--- a/web/src/ui/timeline/content/index.ts
+++ b/web/src/ui/timeline/content/index.ts
@@ -8,6 +8,8 @@ import MemberBody from "./MemberBody.tsx"
import PinnedEventsBody from "./PinnedEventsBody.tsx"
import PowerLevelBody from "./PowerLevelBody.tsx"
import RedactedBody from "./RedactedBody.tsx"
+import RoomAvatarBody from "./RoomAvatarBody.tsx"
+import RoomNameBody from "./RoomNameBody.tsx"
import TextMessageBody from "./TextMessageBody.tsx"
import UnknownMessageBody from "./UnknownMessageBody.tsx"
import EventContentProps from "./props.ts"
@@ -22,6 +24,8 @@ export { default as MemberBody } from "./MemberBody.tsx"
export { default as PinnedEventsBody } from "./PinnedEventsBody.tsx"
export { default as PowerLevelBody } from "./PowerLevelBody.tsx"
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 UnknownMessageBody } from "./UnknownMessageBody.tsx"
export type { default as EventContentProps } from "./props.ts"
@@ -68,6 +72,10 @@ export function getBodyType(evt: MemDBEvent, forReply = false): React.FunctionCo
return EncryptedBody
case "m.room.member":
return MemberBody
+ case "m.room.name":
+ return RoomNameBody
+ case "m.room.avatar":
+ return RoomAvatarBody
case "m.room.server_acl":
return ACLBody
case "m.room.pinned_events":
@@ -82,6 +90,8 @@ export function isSmallEvent(bodyType: React.FunctionComponent