forked from Mirrors/gomuks
web/timeline: add proper rendering of member events
This commit is contained in:
parent
3dc86b287a
commit
e22e72b335
7 changed files with 120 additions and 15 deletions
|
@ -13,11 +13,12 @@
|
||||||
//
|
//
|
||||||
// You should have received a copy of the GNU Affero General Public License
|
// You should have received a copy of the GNU Affero General Public License
|
||||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
import { useCallback, useState } from "react"
|
import { use, useCallback, useState } from "react"
|
||||||
import { getMediaURL } from "@/api/media.ts"
|
import { getMediaURL } from "@/api/media.ts"
|
||||||
import { RoomStateStore } from "@/api/statestore.ts"
|
import { RoomStateStore } from "@/api/statestore.ts"
|
||||||
import { MemDBEvent } from "@/api/types"
|
import { MemDBEvent } from "@/api/types"
|
||||||
import { useNonNullEventAsState } from "@/util/eventdispatcher.ts"
|
import { useNonNullEventAsState } from "@/util/eventdispatcher.ts"
|
||||||
|
import { LightboxContext } from "./Lightbox.tsx"
|
||||||
import MessageComposer from "./MessageComposer.tsx"
|
import MessageComposer from "./MessageComposer.tsx"
|
||||||
import TimelineView from "./timeline/TimelineView.tsx"
|
import TimelineView from "./timeline/TimelineView.tsx"
|
||||||
import "./RoomView.css"
|
import "./RoomView.css"
|
||||||
|
@ -33,6 +34,7 @@ const RoomHeader = ({ room }: RoomViewProps) => {
|
||||||
className="avatar"
|
className="avatar"
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
src={getMediaURL(roomMeta.avatar)}
|
src={getMediaURL(roomMeta.avatar)}
|
||||||
|
onClick={use(LightboxContext)!}
|
||||||
alt=""
|
alt=""
|
||||||
/>
|
/>
|
||||||
<span className="room-name">
|
<span className="room-name">
|
||||||
|
|
|
@ -85,4 +85,9 @@ img.avatar {
|
||||||
height: 2.5rem;
|
height: 2.5rem;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
|
|
||||||
|
&.small {
|
||||||
|
width: 1rem;
|
||||||
|
height: 1rem;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -44,7 +44,7 @@ const ReplyBody = ({ room, eventID, event, onClose }: ReplyBodyProps) => {
|
||||||
<div className="reply-sender">
|
<div className="reply-sender">
|
||||||
<div className="sender-avatar" title={event.sender}>
|
<div className="sender-avatar" title={event.sender}>
|
||||||
<img
|
<img
|
||||||
className="avatar"
|
className="small avatar"
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
src={getAvatarURL(event.sender, memberEvtContent?.avatar_url)}
|
src={getAvatarURL(event.sender, memberEvtContent?.avatar_url)}
|
||||||
alt=""
|
alt=""
|
||||||
|
|
|
@ -90,7 +90,6 @@ div.timeline-event {
|
||||||
grid-template:
|
grid-template:
|
||||||
"timestamp avatar content status" auto
|
"timestamp avatar content status" auto
|
||||||
/ 2.75rem 1.5rem 1fr 2rem;
|
/ 2.75rem 1.5rem 1fr 2rem;
|
||||||
margin-top: 0;
|
|
||||||
|
|
||||||
> div.sender-avatar {
|
> div.sender-avatar {
|
||||||
width: 1.5rem;
|
width: 1.5rem;
|
||||||
|
@ -104,6 +103,10 @@ div.timeline-event {
|
||||||
> div.event-time-only {
|
> div.event-time-only {
|
||||||
display: flex;
|
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;
|
margin-top: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
||||||
> img {
|
|
||||||
width: 1rem;
|
|
||||||
height: 1rem;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
div.date-separator {
|
div.date-separator {
|
||||||
|
|
|
@ -14,13 +14,15 @@
|
||||||
// You should have received a copy of the GNU Affero General Public License
|
// You should have received a copy of the GNU Affero General Public License
|
||||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
import React, { use, useCallback } from "react"
|
import React, { use, useCallback } from "react"
|
||||||
import { getAvatarURL } from "../../api/media.ts"
|
import { getAvatarURL } from "@/api/media.ts"
|
||||||
import { RoomStateStore } from "../../api/statestore.ts"
|
import { RoomStateStore } from "@/api/statestore.ts"
|
||||||
import { MemDBEvent, MemberEventContent } from "../../api/types"
|
import { MemDBEvent, MemberEventContent } from "@/api/types"
|
||||||
import { ClientContext } from "../ClientContext.ts"
|
import { ClientContext } from "../ClientContext.ts"
|
||||||
|
import { LightboxContext } from "../Lightbox.tsx"
|
||||||
import ReplyBody from "./ReplyBody.tsx"
|
import ReplyBody from "./ReplyBody.tsx"
|
||||||
import EncryptedBody from "./content/EncryptedBody.tsx"
|
import EncryptedBody from "./content/EncryptedBody.tsx"
|
||||||
import HiddenEvent from "./content/HiddenEvent.tsx"
|
import HiddenEvent from "./content/HiddenEvent.tsx"
|
||||||
|
import MemberBody from "./content/MemberBody.tsx"
|
||||||
import { MediaMessageBody, TextMessageBody, UnknownMessageBody } from "./content/MessageBody.tsx"
|
import { MediaMessageBody, TextMessageBody, UnknownMessageBody } from "./content/MessageBody.tsx"
|
||||||
import RedactedBody from "./content/RedactedBody.tsx"
|
import RedactedBody from "./content/RedactedBody.tsx"
|
||||||
import { EventContentProps } from "./content/props.ts"
|
import { EventContentProps } from "./content/props.ts"
|
||||||
|
@ -71,6 +73,8 @@ function getBodyType(evt: MemDBEvent): React.FunctionComponent<EventContentProps
|
||||||
return RedactedBody
|
return RedactedBody
|
||||||
}
|
}
|
||||||
return EncryptedBody
|
return EncryptedBody
|
||||||
|
case "m.room.member":
|
||||||
|
return MemberBody
|
||||||
}
|
}
|
||||||
return HiddenEvent
|
return HiddenEvent
|
||||||
}
|
}
|
||||||
|
@ -100,6 +104,10 @@ const EventSendStatus = ({ evt }: { evt: MemDBEvent }) => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isSmallEvent(bodyType: React.FunctionComponent<EventContentProps>): boolean {
|
||||||
|
return bodyType === HiddenEvent || bodyType === MemberBody
|
||||||
|
}
|
||||||
|
|
||||||
const TimelineEvent = ({ room, evt, prevEvt, setReplyTo }: TimelineEventProps) => {
|
const TimelineEvent = ({ room, evt, prevEvt, setReplyTo }: TimelineEventProps) => {
|
||||||
const wrappedSetReplyTo = useCallback(() => setReplyTo(evt), [evt, setReplyTo])
|
const wrappedSetReplyTo = useCallback(() => setReplyTo(evt), [evt, setReplyTo])
|
||||||
const client = use(ClientContext)!
|
const client = use(ClientContext)!
|
||||||
|
@ -109,12 +117,15 @@ const TimelineEvent = ({ room, evt, prevEvt, setReplyTo }: TimelineEventProps) =
|
||||||
const eventTS = new Date(evt.timestamp)
|
const eventTS = new Date(evt.timestamp)
|
||||||
const editEventTS = evt.last_edit ? new Date(evt.last_edit.timestamp) : null
|
const editEventTS = evt.last_edit ? new Date(evt.last_edit.timestamp) : null
|
||||||
const wrapperClassNames = ["timeline-event"]
|
const wrapperClassNames = ["timeline-event"]
|
||||||
if (BodyType === HiddenEvent) {
|
let smallAvatar = false
|
||||||
|
if (isSmallEvent(BodyType)) {
|
||||||
wrapperClassNames.push("hidden-event")
|
wrapperClassNames.push("hidden-event")
|
||||||
|
smallAvatar = true
|
||||||
} else if (prevEvt?.sender === evt.sender &&
|
} else if (prevEvt?.sender === evt.sender &&
|
||||||
prevEvt.timestamp + 15 * 60 * 1000 > evt.timestamp &&
|
prevEvt.timestamp + 15 * 60 * 1000 > evt.timestamp &&
|
||||||
getBodyType(prevEvt) !== HiddenEvent) {
|
!isSmallEvent(getBodyType(prevEvt))) {
|
||||||
wrapperClassNames.push("same-sender")
|
wrapperClassNames.push("same-sender")
|
||||||
|
smallAvatar = true
|
||||||
}
|
}
|
||||||
const fullTime = fullTimeFormatter.format(eventTS)
|
const fullTime = fullTimeFormatter.format(eventTS)
|
||||||
const shortTime = formatShortTime(eventTS)
|
const shortTime = formatShortTime(eventTS)
|
||||||
|
@ -123,9 +134,10 @@ const TimelineEvent = ({ room, evt, prevEvt, setReplyTo }: TimelineEventProps) =
|
||||||
const mainEvent = <div className={wrapperClassNames.join(" ")}>
|
const mainEvent = <div className={wrapperClassNames.join(" ")}>
|
||||||
<div className="sender-avatar" title={evt.sender}>
|
<div className="sender-avatar" title={evt.sender}>
|
||||||
<img
|
<img
|
||||||
className="avatar"
|
className={`${smallAvatar ? "small" : ""} avatar`}
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
src={getAvatarURL(evt.sender, memberEvtContent?.avatar_url)}
|
src={getAvatarURL(evt.sender, memberEvtContent?.avatar_url)}
|
||||||
|
onClick={use(LightboxContext)!}
|
||||||
alt=""
|
alt=""
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
@ -142,7 +154,7 @@ const TimelineEvent = ({ room, evt, prevEvt, setReplyTo }: TimelineEventProps) =
|
||||||
<div className="event-content">
|
<div className="event-content">
|
||||||
{typeof replyTo === "string" && BodyType !== HiddenEvent
|
{typeof replyTo === "string" && BodyType !== HiddenEvent
|
||||||
? <ReplyBody room={room} eventID={replyTo}/> : null}
|
? <ReplyBody room={room} eventID={replyTo}/> : null}
|
||||||
<BodyType room={room} event={evt}/>
|
<BodyType room={room} sender={memberEvt} event={evt}/>
|
||||||
{evt.reactions ? <EventReactions reactions={evt.reactions}/> : null}
|
{evt.reactions ? <EventReactions reactions={evt.reactions}/> : null}
|
||||||
</div>
|
</div>
|
||||||
{evt.sender === client.userID && evt.transaction_id ? <EventSendStatus evt={evt}/> : null}
|
{evt.sender === client.userID && evt.transaction_id ? <EventSendStatus evt={evt}/> : null}
|
||||||
|
|
87
web/src/ui/timeline/content/MemberBody.tsx
Normal file
87
web/src/ui/timeline/content/MemberBody.tsx
Normal file
|
@ -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 <https://www.gnu.org/licenses/>.
|
||||||
|
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 = <img
|
||||||
|
className="small avatar"
|
||||||
|
loading="lazy"
|
||||||
|
src={getAvatarURL(target, content?.avatar_url)}
|
||||||
|
onClick={use(LightboxContext)!}
|
||||||
|
alt=""
|
||||||
|
/>
|
||||||
|
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 <img
|
||||||
|
className="small avatar"
|
||||||
|
loading="lazy"
|
||||||
|
height={16}
|
||||||
|
src={getAvatarURL(target, prevContent?.avatar_url)}
|
||||||
|
onClick={use(LightboxContext)!}
|
||||||
|
alt=""
|
||||||
|
/> 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 <div className="member-body">
|
||||||
|
{sender?.content.displayname ?? event.sender} {
|
||||||
|
useChangeDescription(event.sender, event.state_key as UserID, content, prevContent)
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
export default MemberBody
|
|
@ -19,4 +19,5 @@ import { MemDBEvent } from "../../../api/types"
|
||||||
export interface EventContentProps {
|
export interface EventContentProps {
|
||||||
room: RoomStateStore
|
room: RoomStateStore
|
||||||
event: MemDBEvent
|
event: MemDBEvent
|
||||||
|
sender?: MemDBEvent
|
||||||
}
|
}
|
||||||
|
|
Loading…
Add table
Reference in a new issue