diff --git a/web/src/ui/RoomView.tsx b/web/src/ui/RoomView.tsx index 1a504e6..a4ff213 100644 --- a/web/src/ui/RoomView.tsx +++ b/web/src/ui/RoomView.tsx @@ -13,11 +13,12 @@ // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -import { useCallback, useState } from "react" +import { use, useCallback, useState } from "react" import { getMediaURL } from "@/api/media.ts" import { RoomStateStore } from "@/api/statestore.ts" import { MemDBEvent } from "@/api/types" import { useNonNullEventAsState } from "@/util/eventdispatcher.ts" +import { LightboxContext } from "./Lightbox.tsx" import MessageComposer from "./MessageComposer.tsx" import TimelineView from "./timeline/TimelineView.tsx" import "./RoomView.css" @@ -33,6 +34,7 @@ const RoomHeader = ({ room }: RoomViewProps) => { className="avatar" loading="lazy" src={getMediaURL(roomMeta.avatar)} + onClick={use(LightboxContext)!} alt="" /> diff --git a/web/src/ui/roomlist/RoomList.css b/web/src/ui/roomlist/RoomList.css index c908f85..36693bc 100644 --- a/web/src/ui/roomlist/RoomList.css +++ b/web/src/ui/roomlist/RoomList.css @@ -85,4 +85,9 @@ img.avatar { height: 2.5rem; border-radius: 50%; object-fit: cover; + + &.small { + width: 1rem; + height: 1rem; + } } diff --git a/web/src/ui/timeline/ReplyBody.tsx b/web/src/ui/timeline/ReplyBody.tsx index b1ae263..66647de 100644 --- a/web/src/ui/timeline/ReplyBody.tsx +++ b/web/src/ui/timeline/ReplyBody.tsx @@ -44,7 +44,7 @@ const ReplyBody = ({ room, eventID, event, onClose }: ReplyBodyProps) => {
div.sender-avatar { width: 1.5rem; @@ -104,6 +103,10 @@ div.timeline-event { > div.event-time-only { display: flex; } + + + div.timeline-event.hidden-event { + margin-top: 0; + } } } @@ -111,11 +114,6 @@ div.hidden-event > div.sender-avatar, blockquote.reply-body > div.reply-sender > margin-top: 0; display: flex; align-items: center; - - > img { - width: 1rem; - height: 1rem; - } } div.date-separator { diff --git a/web/src/ui/timeline/TimelineEvent.tsx b/web/src/ui/timeline/TimelineEvent.tsx index c3bdaaa..62d8dc2 100644 --- a/web/src/ui/timeline/TimelineEvent.tsx +++ b/web/src/ui/timeline/TimelineEvent.tsx @@ -14,13 +14,15 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . import React, { use, useCallback } from "react" -import { getAvatarURL } from "../../api/media.ts" -import { RoomStateStore } from "../../api/statestore.ts" -import { MemDBEvent, MemberEventContent } from "../../api/types" +import { getAvatarURL } from "@/api/media.ts" +import { RoomStateStore } from "@/api/statestore.ts" +import { MemDBEvent, MemberEventContent } from "@/api/types" import { ClientContext } from "../ClientContext.ts" +import { LightboxContext } from "../Lightbox.tsx" import ReplyBody from "./ReplyBody.tsx" import EncryptedBody from "./content/EncryptedBody.tsx" import HiddenEvent from "./content/HiddenEvent.tsx" +import MemberBody from "./content/MemberBody.tsx" import { MediaMessageBody, TextMessageBody, UnknownMessageBody } from "./content/MessageBody.tsx" import RedactedBody from "./content/RedactedBody.tsx" import { EventContentProps } from "./content/props.ts" @@ -71,6 +73,8 @@ function getBodyType(evt: MemDBEvent): React.FunctionComponent { } } +function isSmallEvent(bodyType: React.FunctionComponent): boolean { + return bodyType === HiddenEvent || bodyType === MemberBody +} + const TimelineEvent = ({ room, evt, prevEvt, setReplyTo }: TimelineEventProps) => { const wrappedSetReplyTo = useCallback(() => setReplyTo(evt), [evt, setReplyTo]) const client = use(ClientContext)! @@ -109,12 +117,15 @@ const TimelineEvent = ({ room, evt, prevEvt, setReplyTo }: TimelineEventProps) = const eventTS = new Date(evt.timestamp) const editEventTS = evt.last_edit ? new Date(evt.last_edit.timestamp) : null const wrapperClassNames = ["timeline-event"] - if (BodyType === HiddenEvent) { + let smallAvatar = false + if (isSmallEvent(BodyType)) { wrapperClassNames.push("hidden-event") + smallAvatar = true } else if (prevEvt?.sender === evt.sender && prevEvt.timestamp + 15 * 60 * 1000 > evt.timestamp && - getBodyType(prevEvt) !== HiddenEvent) { + !isSmallEvent(getBodyType(prevEvt))) { wrapperClassNames.push("same-sender") + smallAvatar = true } const fullTime = fullTimeFormatter.format(eventTS) const shortTime = formatShortTime(eventTS) @@ -123,9 +134,10 @@ const TimelineEvent = ({ room, evt, prevEvt, setReplyTo }: TimelineEventProps) = const mainEvent =
@@ -142,7 +154,7 @@ const TimelineEvent = ({ room, evt, prevEvt, setReplyTo }: TimelineEventProps) =
{typeof replyTo === "string" && BodyType !== HiddenEvent ? : null} - + {evt.reactions ? : null}
{evt.sender === client.userID && evt.transaction_id ? : null} diff --git a/web/src/ui/timeline/content/MemberBody.tsx b/web/src/ui/timeline/content/MemberBody.tsx new file mode 100644 index 0000000..fb06294 --- /dev/null +++ b/web/src/ui/timeline/content/MemberBody.tsx @@ -0,0 +1,87 @@ +// 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 React, { use } from "react" +import { getAvatarURL } from "@/api/media.ts" +import { MemberEventContent, UserID } from "@/api/types" +import { LightboxContext } from "../../Lightbox.tsx" +import { EventContentProps } from "./props.ts" + +function useChangeDescription( + sender: UserID, target: UserID, content: MemberEventContent, prevContent?: MemberEventContent, +): string | React.ReactElement { + const targetAvatar = + if (content.membership === prevContent?.membership) { + if (content.displayname !== prevContent.displayname) { + if (content.avatar_url !== prevContent.avatar_url) { + return "changed their displayname and avatar" + } else if (!content.displayname) { + return "removed their displayname" + } else if (!prevContent.displayname) { + return `set their displayname to ${content.displayname}` + } + return `changed their displayname from ${prevContent.displayname} to ${content.displayname}` + } else if (content.avatar_url !== prevContent.avatar_url) { + if (!content.avatar_url) { + return "removed their avatar" + } else if (!prevContent.avatar_url) { + return <>set their avatar to {targetAvatar} + } + return <> + changed their avatar from to {targetAvatar} + + } + return "made no change" + } else if (content.membership === "join") { + return "joined the room" + } else if (content.membership === "invite") { + return <>invited {content.avatar_url && targetAvatar} {content.displayname ?? target} + } else if (content.membership === "ban") { + return <>banned {content.avatar_url && targetAvatar} {content.displayname ?? target} + } else if (content.membership === "knock") { + return "knocked on the room" + } else if (content.membership === "leave") { + if (sender === target) { + return "left the room" + } + return <>kicked {content.displayname} + } + return "made an unknown membership change" +} + +const MemberBody = ({ event, sender }: EventContentProps) => { + const content = event.content as MemberEventContent + const prevContent = event.unsigned.prev_content as MemberEventContent | undefined + return
+ {sender?.content.displayname ?? event.sender} { + useChangeDescription(event.sender, event.state_key as UserID, content, prevContent) + } +
+} + +export default MemberBody diff --git a/web/src/ui/timeline/content/props.ts b/web/src/ui/timeline/content/props.ts index 91c394f..2321702 100644 --- a/web/src/ui/timeline/content/props.ts +++ b/web/src/ui/timeline/content/props.ts @@ -19,4 +19,5 @@ import { MemDBEvent } from "../../../api/types" export interface EventContentProps { room: RoomStateStore event: MemDBEvent + sender?: MemDBEvent }