mirror of
https://github.com/tulir/gomuks.git
synced 2025-04-19 18:13:41 -05:00
web/timeline: add basic reply rendering
This commit is contained in:
parent
e909f4f994
commit
ec165d171c
6 changed files with 198 additions and 90 deletions
|
@ -62,3 +62,43 @@ export interface MemberEventContent {
|
|||
avatar_url?: ContentURI
|
||||
reason?: string
|
||||
}
|
||||
|
||||
export interface BaseMessageEventContent {
|
||||
msgtype: string
|
||||
body: string
|
||||
formatted_body?: string
|
||||
format?: "org.matrix.custom.html"
|
||||
}
|
||||
|
||||
export interface TextMessageEventContent extends BaseMessageEventContent {
|
||||
msgtype: "m.text" | "m.notice" | "m.emote"
|
||||
}
|
||||
|
||||
export interface MediaMessageEventContent extends BaseMessageEventContent {
|
||||
msgtype: "m.image" | "m.file" | "m.audio" | "m.video"
|
||||
filename?: string
|
||||
url?: ContentURI
|
||||
file?: {
|
||||
url: ContentURI
|
||||
k: string
|
||||
v: "v2"
|
||||
ext: true
|
||||
alg: "A256CTR"
|
||||
key_ops: string[]
|
||||
kty: "oct"
|
||||
}
|
||||
info?: {
|
||||
mimetype?: string
|
||||
size?: number
|
||||
w?: number
|
||||
h?: number
|
||||
duration?: number
|
||||
}
|
||||
}
|
||||
|
||||
export interface LocationMessageEventContent extends BaseMessageEventContent {
|
||||
msgtype: "m.location"
|
||||
geo_uri: string
|
||||
}
|
||||
|
||||
export type MessageEventContent = TextMessageEventContent | MediaMessageEventContent | LocationMessageEventContent
|
||||
|
|
33
web/src/ui/timeline/ReplyBody.css
Normal file
33
web/src/ui/timeline/ReplyBody.css
Normal file
|
@ -0,0 +1,33 @@
|
|||
blockquote.reply-body {
|
||||
margin: 0;
|
||||
border-left: 2px solid #aaa;
|
||||
padding: .25rem .5rem;
|
||||
|
||||
&:hover {
|
||||
border-color: black;
|
||||
|
||||
> div.message-text {
|
||||
color: black;
|
||||
}
|
||||
}
|
||||
|
||||
> div.message-text {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
color: #666;
|
||||
|
||||
}
|
||||
|
||||
> div.reply-sender {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
> div.sender-avatar {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
margin-right: .25rem;
|
||||
}
|
||||
}
|
||||
}
|
52
web/src/ui/timeline/ReplyBody.tsx
Normal file
52
web/src/ui/timeline/ReplyBody.tsx
Normal file
|
@ -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 <https://www.gnu.org/licenses/>.
|
||||
import { getAvatarURL } from "@/api/media.ts"
|
||||
import type { RoomStateStore } from "@/api/statestore.ts"
|
||||
import type { EventID, MemberEventContent } from "@/api/types"
|
||||
import { TextMessageBody } from "./content/MessageBody.tsx"
|
||||
import "./ReplyBody.css"
|
||||
|
||||
interface ReplyBodyProps {
|
||||
room: RoomStateStore
|
||||
eventID: EventID
|
||||
}
|
||||
|
||||
const ReplyBody = ({ room, eventID }: ReplyBodyProps) => {
|
||||
const evt = room.eventsByID.get(eventID)
|
||||
if (!evt) {
|
||||
return <blockquote className="reply-body">
|
||||
Reply to {eventID}
|
||||
</blockquote>
|
||||
}
|
||||
const memberEvt = room.getStateEvent("m.room.member", evt.sender)
|
||||
const memberEvtContent = memberEvt?.content as MemberEventContent | undefined
|
||||
return <blockquote className="reply-body">
|
||||
<div className="reply-sender">
|
||||
<div className="sender-avatar" title={evt.sender}>
|
||||
<img
|
||||
className="avatar"
|
||||
loading="lazy"
|
||||
src={getAvatarURL(evt.sender, memberEvtContent?.avatar_url)}
|
||||
alt=""
|
||||
/>
|
||||
</div>
|
||||
<span className="event-sender">{memberEvtContent?.displayname ?? evt.sender}</span>
|
||||
</div>
|
||||
<TextMessageBody room={room} event={evt}/>
|
||||
</blockquote>
|
||||
}
|
||||
|
||||
export default ReplyBody
|
|
@ -93,16 +93,8 @@ div.timeline-event {
|
|||
margin-top: 0;
|
||||
|
||||
> div.sender-avatar {
|
||||
margin-top: 0;
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
> img {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
> div.event-sender-and-time {
|
||||
|
@ -115,6 +107,17 @@ div.timeline-event {
|
|||
}
|
||||
}
|
||||
|
||||
div.hidden-event > div.sender-avatar, blockquote.reply-body > div.reply-sender > div.sender-avatar {
|
||||
margin-top: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
> img {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
div.date-separator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
@ -146,6 +149,10 @@ div.redacted-body, div.decryption-pending-body {
|
|||
}
|
||||
}
|
||||
|
||||
div.plaintext-body {
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
div.html-body {
|
||||
overflow: hidden;
|
||||
img[data-mx-emoticon] {
|
||||
|
|
|
@ -18,9 +18,10 @@ import { getAvatarURL } from "../../api/media.ts"
|
|||
import { RoomStateStore } from "../../api/statestore.ts"
|
||||
import { MemDBEvent, MemberEventContent } from "../../api/types"
|
||||
import { ClientContext } from "../ClientContext.ts"
|
||||
import ReplyBody from "./ReplyBody.tsx"
|
||||
import EncryptedBody from "./content/EncryptedBody.tsx"
|
||||
import HiddenEvent from "./content/HiddenEvent.tsx"
|
||||
import MessageBody from "./content/MessageBody.tsx"
|
||||
import { MediaMessageBody, TextMessageBody, UnknownMessageBody } from "./content/MessageBody.tsx"
|
||||
import RedactedBody from "./content/RedactedBody.tsx"
|
||||
import { EventContentProps } from "./content/props.ts"
|
||||
import ErrorIcon from "../../icons/error.svg?react"
|
||||
|
@ -44,7 +45,22 @@ function getBodyType(evt: MemDBEvent): React.FunctionComponent<EventContentProps
|
|||
if (evt.redacted_by) {
|
||||
return RedactedBody
|
||||
}
|
||||
return MessageBody
|
||||
switch (evt.content.msgtype) {
|
||||
case "m.text":
|
||||
case "m.notice":
|
||||
case "m.emote":
|
||||
return TextMessageBody
|
||||
case "m.image":
|
||||
case "m.video":
|
||||
case "m.audio":
|
||||
case "m.file":
|
||||
return MediaMessageBody
|
||||
case "m.location":
|
||||
// return LocationMessageBody
|
||||
// fallthrough
|
||||
default:
|
||||
return UnknownMessageBody
|
||||
}
|
||||
case "m.room.encrypted":
|
||||
if (evt.redacted_by) {
|
||||
return RedactedBody
|
||||
|
@ -97,6 +113,7 @@ const TimelineEvent = ({ room, evt, prevEvt }: TimelineEventProps) => {
|
|||
const fullTime = fullTimeFormatter.format(eventTS)
|
||||
const shortTime = formatShortTime(eventTS)
|
||||
const editTime = editEventTS ? `Edited at ${fullTimeFormatter.format(editEventTS)}` : null
|
||||
const replyTo = evt.content["m.relates_to"]?.["m.in_reply_to"]?.event_id
|
||||
const mainEvent = <div className={wrapperClassNames.join(" ")}>
|
||||
<div className="sender-avatar" title={evt.sender}>
|
||||
<img
|
||||
|
@ -117,6 +134,8 @@ const TimelineEvent = ({ room, evt, prevEvt }: TimelineEventProps) => {
|
|||
<span className="event-time" title={editTime ? `${fullTime} - ${editTime}` : fullTime}>{shortTime}</span>
|
||||
</div>
|
||||
<div className="event-content">
|
||||
{typeof replyTo === "string" && BodyType !== HiddenEvent
|
||||
? <ReplyBody room={room} eventID={replyTo}/> : null}
|
||||
<BodyType room={room} event={evt}/>
|
||||
{evt.reactions ? <EventReactions reactions={evt.reactions}/> : null}
|
||||
</div>
|
||||
|
|
|
@ -15,53 +15,13 @@
|
|||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
import { use, useMemo } from "react"
|
||||
import sanitizeHtml from "sanitize-html"
|
||||
import { getMediaURL } from "../../../api/media.ts"
|
||||
import { ContentURI } from "../../../api/types"
|
||||
import { sanitizeHtmlParams } from "../../../util/html.ts"
|
||||
import { calculateMediaSize } from "../../../util/mediasize.ts"
|
||||
import { getMediaURL } from "@/api/media.ts"
|
||||
import type { MediaMessageEventContent, MessageEventContent } from "@/api/types"
|
||||
import { sanitizeHtmlParams } from "@/util/html.ts"
|
||||
import { calculateMediaSize } from "@/util/mediasize.ts"
|
||||
import { LightboxContext } from "../../Lightbox.tsx"
|
||||
import { EventContentProps } from "./props.ts"
|
||||
|
||||
interface BaseMessageEventContent {
|
||||
msgtype: string
|
||||
body: string
|
||||
formatted_body?: string
|
||||
format?: "org.matrix.custom.html"
|
||||
}
|
||||
|
||||
interface TextMessageEventContent extends BaseMessageEventContent {
|
||||
msgtype: "m.text" | "m.notice" | "m.emote"
|
||||
}
|
||||
|
||||
interface MediaMessageEventContent extends BaseMessageEventContent {
|
||||
msgtype: "m.image" | "m.file" | "m.audio" | "m.video"
|
||||
filename?: string
|
||||
url?: ContentURI
|
||||
file?: {
|
||||
url: ContentURI
|
||||
k: string
|
||||
v: "v2"
|
||||
ext: true
|
||||
alg: "A256CTR"
|
||||
key_ops: string[]
|
||||
kty: "oct"
|
||||
}
|
||||
info?: {
|
||||
mimetype?: string
|
||||
size?: number
|
||||
w?: number
|
||||
h?: number
|
||||
duration?: number
|
||||
}
|
||||
}
|
||||
|
||||
interface LocationMessageEventContent extends BaseMessageEventContent {
|
||||
msgtype: "m.location"
|
||||
geo_uri: string
|
||||
}
|
||||
|
||||
type MessageEventContent = TextMessageEventContent | MediaMessageEventContent | LocationMessageEventContent
|
||||
|
||||
const onClickHTML = (evt: React.MouseEvent<HTMLDivElement>) => {
|
||||
if ((evt.target as HTMLElement).closest("span[data-mx-spoiler]")?.classList.toggle("spoiler-revealed")) {
|
||||
// When unspoilering, don't trigger links and other clickables inside the spoiler
|
||||
|
@ -69,49 +29,46 @@ const onClickHTML = (evt: React.MouseEvent<HTMLDivElement>) => {
|
|||
}
|
||||
}
|
||||
|
||||
const MessageBody = ({ event }: EventContentProps) => {
|
||||
export const TextMessageBody = ({ event }: EventContentProps) => {
|
||||
const content = event.content as MessageEventContent
|
||||
if (event.type === "m.sticker") {
|
||||
content.msgtype = "m.image"
|
||||
}
|
||||
const __html = useMemo(() => {
|
||||
if (content.format === "org.matrix.custom.html") {
|
||||
return sanitizeHtml(content.formatted_body!, sanitizeHtmlParams)
|
||||
}
|
||||
return undefined
|
||||
}, [content.format, content.formatted_body])
|
||||
switch (content.msgtype) {
|
||||
case "m.text":
|
||||
case "m.emote":
|
||||
case "m.notice":
|
||||
if (__html) {
|
||||
return <div onClick={onClickHTML} className="html-body" dangerouslySetInnerHTML={{ __html }}/>
|
||||
}
|
||||
return content.body
|
||||
case "m.image": {
|
||||
const openLightbox = use(LightboxContext)
|
||||
const style = calculateMediaSize(content.info?.w, content.info?.h)
|
||||
let caption = null
|
||||
if (__html) {
|
||||
caption = <div onClick={onClickHTML} className="html-body" dangerouslySetInnerHTML={{ __html }}/>
|
||||
} else if (content.body && content.filename && content.body !== content.filename) {
|
||||
caption = content.body
|
||||
}
|
||||
return <>
|
||||
<div className="media-container" style={style.container}>
|
||||
<img
|
||||
loading="lazy"
|
||||
style={style.media}
|
||||
src={getMediaURL(content.url ?? content.file?.url)}
|
||||
alt={content.filename ?? content.body}
|
||||
onClick={openLightbox}
|
||||
/>
|
||||
</div>
|
||||
{caption}
|
||||
</>
|
||||
if (__html) {
|
||||
return <div onClick={onClickHTML} className="message-text html-body" dangerouslySetInnerHTML={{ __html }}/>
|
||||
}
|
||||
}
|
||||
return <code>{`{ "type": "${event.type}", "content": { "msgtype": "${content.msgtype}" } }`}</code>
|
||||
return <div className="message-text plaintext-body">{content.body}</div>
|
||||
}
|
||||
|
||||
export default MessageBody
|
||||
export const MediaMessageBody = ({ event, room }: EventContentProps) => {
|
||||
const content = event.content as MediaMessageEventContent
|
||||
if (event.type === "m.sticker") {
|
||||
content.msgtype = "m.image"
|
||||
}
|
||||
const openLightbox = use(LightboxContext)
|
||||
const style = calculateMediaSize(content.info?.w, content.info?.h)
|
||||
let caption = null
|
||||
if (content.body && content.filename && content.body !== content.filename) {
|
||||
caption = <TextMessageBody event={event} room={room} />
|
||||
}
|
||||
return <>
|
||||
<div className="media-container" style={style.container}>
|
||||
<img
|
||||
loading="lazy"
|
||||
style={style.media}
|
||||
src={getMediaURL(content.url ?? content.file?.url)}
|
||||
alt={content.filename ?? content.body}
|
||||
onClick={openLightbox}
|
||||
/>
|
||||
</div>
|
||||
{caption}
|
||||
</>
|
||||
}
|
||||
|
||||
export const UnknownMessageBody = ({ event }: EventContentProps) => {
|
||||
const content = event.content as MessageEventContent
|
||||
return <code>{`{ "type": "${event.type}", "content": { "msgtype": "${content.msgtype}" } }`}</code>
|
||||
}
|
||||
|
|
Loading…
Add table
Reference in a new issue