diff --git a/web/src/api/types/mxtypes.ts b/web/src/api/types/mxtypes.ts index 9e8fc8d..469d5f9 100644 --- a/web/src/api/types/mxtypes.ts +++ b/web/src/api/types/mxtypes.ts @@ -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 diff --git a/web/src/ui/timeline/ReplyBody.css b/web/src/ui/timeline/ReplyBody.css new file mode 100644 index 0000000..7c11623 --- /dev/null +++ b/web/src/ui/timeline/ReplyBody.css @@ -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; + } + } +} diff --git a/web/src/ui/timeline/ReplyBody.tsx b/web/src/ui/timeline/ReplyBody.tsx new file mode 100644 index 0000000..4588b8a --- /dev/null +++ b/web/src/ui/timeline/ReplyBody.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 { 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
+ Reply to {eventID} +
+ } + const memberEvt = room.getStateEvent("m.room.member", evt.sender) + const memberEvtContent = memberEvt?.content as MemberEventContent | undefined + return
+
+
+ +
+ {memberEvtContent?.displayname ?? evt.sender} +
+ +
+} + +export default ReplyBody diff --git a/web/src/ui/timeline/TimelineEvent.css b/web/src/ui/timeline/TimelineEvent.css index 259b58c..e543506 100644 --- a/web/src/ui/timeline/TimelineEvent.css +++ b/web/src/ui/timeline/TimelineEvent.css @@ -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] { diff --git a/web/src/ui/timeline/TimelineEvent.tsx b/web/src/ui/timeline/TimelineEvent.tsx index dcf6d6e..0d23a6c 100644 --- a/web/src/ui/timeline/TimelineEvent.tsx +++ b/web/src/ui/timeline/TimelineEvent.tsx @@ -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 { 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 =
{ {shortTime}
+ {typeof replyTo === "string" && BodyType !== HiddenEvent + ? : null} {evt.reactions ? : null}
diff --git a/web/src/ui/timeline/content/MessageBody.tsx b/web/src/ui/timeline/content/MessageBody.tsx index cc3e948..1e05a51 100644 --- a/web/src/ui/timeline/content/MessageBody.tsx +++ b/web/src/ui/timeline/content/MessageBody.tsx @@ -15,53 +15,13 @@ // along with this program. If not, see . 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) => { 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) => { } } -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
- } - 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 =
- } else if (content.body && content.filename && content.body !== content.filename) { - caption = content.body - } - return <> -
- {content.filename -
- {caption} - + if (__html) { + return
} - } - return {`{ "type": "${event.type}", "content": { "msgtype": "${content.msgtype}" } }`} + return
{content.body}
} -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 = + } + return <> +
+ {content.filename +
+ {caption} + +} + +export const UnknownMessageBody = ({ event }: EventContentProps) => { + const content = event.content as MessageEventContent + return {`{ "type": "${event.type}", "content": { "msgtype": "${content.msgtype}" } }`} +}