web/timeline: add support for other file types

This commit is contained in:
Tulir Asokan 2024-10-13 18:27:11 +03:00
parent 11aa2eabf1
commit 6c55f1654c
6 changed files with 125 additions and 41 deletions

View file

@ -37,14 +37,14 @@ export default tseslint.config(
"named": true, "named": true,
"warnOnUnassignedImports": true, "warnOnUnassignedImports": true,
"pathGroups": [{ "pathGroups": [{
"pattern": "@/**",
"group": "parent",
"position": "before",
}, {
"pattern": "*.svg?react", "pattern": "*.svg?react",
"patternOptions": {"matchBase": true}, "patternOptions": {"matchBase": true},
"group": "sibling", "group": "sibling",
"position": "after", "position": "after",
}, {
"pattern": "@/**",
"group": "parent",
"position": "before",
}, { }, {
"pattern": "*.css", "pattern": "*.css",
"patternOptions": {"matchBase": true}, "patternOptions": {"matchBase": true},

View file

@ -18,7 +18,7 @@ import { UserID } from "./types"
const mediaRegex = /^mxc:\/\/([a-zA-Z0-9.:-]+)\/([a-zA-Z0-9_-]+)$/ const mediaRegex = /^mxc:\/\/([a-zA-Z0-9.:-]+)\/([a-zA-Z0-9_-]+)$/
export const getMediaURL = (mxc?: string): string | undefined => { export const getMediaURL = (mxc?: string, encrypted: boolean = false): string | undefined => {
if (!mxc) { if (!mxc) {
return undefined return undefined
} }
@ -26,7 +26,11 @@ export const getMediaURL = (mxc?: string): string | undefined => {
if (!match) { if (!match) {
return undefined return undefined
} }
return `_gomuks/media/${match[1]}/${match[2]}` return `_gomuks/media/${match[1]}/${match[2]}?encrypted=${encrypted}`
}
export const getEncryptedMediaURL = (mxc?: string): string | undefined => {
return getMediaURL(mxc, true)
} }
export const getAvatarURL = (_userID: UserID, mxc?: string): string | undefined => { export const getAvatarURL = (_userID: UserID, mxc?: string): string | undefined => {

View file

@ -79,22 +79,32 @@ export interface MediaMessageEventContent extends BaseMessageEventContent {
msgtype: "m.image" | "m.file" | "m.audio" | "m.video" msgtype: "m.image" | "m.file" | "m.audio" | "m.video"
filename?: string filename?: string
url?: ContentURI url?: ContentURI
file?: { file?: EncryptedFile
url: ContentURI info?: MediaInfo
k: string }
v: "v2"
ext: true export interface EncryptedFile {
alg: "A256CTR" url: ContentURI
key_ops: string[] k: string
kty: "oct" v: "v2"
} ext: true
info?: { alg: "A256CTR"
mimetype?: string key_ops: string[]
size?: number kty: "oct"
w?: number }
h?: number
duration?: number export interface MediaInfo {
} mimetype?: string
size?: number
w?: number
h?: number
duration?: number
thumbnail_url?: ContentURI
thumbnail_file?: EncryptedFile
thumbnail_info?: MediaInfo
"fi.mau.hide_controls"?: boolean
"fi.mau.loop"?: boolean
} }
export interface LocationMessageEventContent extends BaseMessageEventContent { export interface LocationMessageEventContent extends BaseMessageEventContent {

View file

@ -199,8 +199,25 @@ div.html-body {
} }
div.media-container { div.media-container {
&.video-container {
width: 100%;
height: 240px;
}
> a {
display: flex;
align-items: center;
text-decoration: none;
color: inherit;
}
> img { > img {
max-width: 100%; max-width: 100%;
max-height: 100%; max-height: 100%;
} }
> video {
min-width: 320px;
max-width: 100%;
height: 100%;
}
} }

View file

@ -41,7 +41,6 @@ function getBodyType(evt: MemDBEvent): React.FunctionComponent<EventContentProps
} }
switch (evt.type) { switch (evt.type) {
case "m.room.message": case "m.room.message":
case "m.sticker":
if (evt.redacted_by) { if (evt.redacted_by) {
return RedactedBody return RedactedBody
} }
@ -56,11 +55,16 @@ function getBodyType(evt: MemDBEvent): React.FunctionComponent<EventContentProps
case "m.file": case "m.file":
return MediaMessageBody return MediaMessageBody
case "m.location": case "m.location":
// return LocationMessageBody // return LocationMessageBody
// fallthrough // fallthrough
default: default:
return UnknownMessageBody return UnknownMessageBody
} }
case "m.sticker":
if (evt.redacted_by) {
return RedactedBody
}
return MediaMessageBody
case "m.room.encrypted": case "m.room.encrypted":
if (evt.redacted_by) { if (evt.redacted_by) {
return RedactedBody return RedactedBody

View file

@ -13,14 +13,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 { use, useMemo } from "react" import { CSSProperties, use, useMemo } from "react"
import sanitizeHtml from "sanitize-html" import sanitizeHtml from "sanitize-html"
import { getMediaURL } from "@/api/media.ts" import { getEncryptedMediaURL, getMediaURL } from "@/api/media.ts"
import type { MediaMessageEventContent, MessageEventContent } from "@/api/types" import type { EventType, MediaMessageEventContent, MessageEventContent } from "@/api/types"
import { sanitizeHtmlParams } from "@/util/html.ts" import { sanitizeHtmlParams } from "@/util/html.ts"
import { calculateMediaSize } from "@/util/mediasize.ts" import { calculateMediaSize } from "@/util/mediasize.ts"
import { LightboxContext } from "../../Lightbox.tsx" import { LightboxContext } from "../../Lightbox.tsx"
import { EventContentProps } from "./props.ts" import { EventContentProps } from "./props.ts"
import DownloadIcon from "@/icons/download.svg?react"
const onClickHTML = (evt: React.MouseEvent<HTMLDivElement>) => { const onClickHTML = (evt: React.MouseEvent<HTMLDivElement>) => {
if ((evt.target as HTMLElement).closest("span[data-mx-spoiler]")?.classList.toggle("spoiler-revealed")) { if ((evt.target as HTMLElement).closest("span[data-mx-spoiler]")?.classList.toggle("spoiler-revealed")) {
@ -43,26 +44,74 @@ export const TextMessageBody = ({ event }: EventContentProps) => {
return <div className="message-text plaintext-body">{content.body}</div> return <div className="message-text plaintext-body">{content.body}</div>
} }
const useMediaContent = (
content: MediaMessageEventContent, evtType: EventType,
): [React.ReactElement | null, string, CSSProperties] => {
const mediaURL = content.url ? getMediaURL(content.url) : getEncryptedMediaURL(content.file?.url)
const thumbnailURL = content.info?.thumbnail_url
? getMediaURL(content.info.thumbnail_url) : getEncryptedMediaURL(content.info?.thumbnail_file?.url)
if (content.msgtype === "m.image" || evtType === "m.sticker") {
const style = calculateMediaSize(content.info?.w, content.info?.h)
return [<img
loading="lazy"
style={style.media}
src={mediaURL}
alt={content.filename ?? content.body}
onClick={use(LightboxContext)}
/>, "image-container", style.container]
} else if (content.msgtype === "m.video") {
const autoplay = false
const controls = !content.info?.["fi.mau.hide_controls"]
const loop = !!content.info?.["fi.mau.loop"]
let onMouseOver: React.MouseEventHandler<HTMLVideoElement> | undefined
let onMouseOut: React.MouseEventHandler<HTMLVideoElement> | undefined
if (!autoplay && !controls) {
onMouseOver = (event: React.MouseEvent<HTMLVideoElement>) => event.currentTarget.play()
onMouseOut = (event: React.MouseEvent<HTMLVideoElement>) => {
event.currentTarget.pause()
event.currentTarget.currentTime = 0
}
}
return [<video
autoPlay={autoplay}
controls={controls}
loop={loop}
poster={thumbnailURL}
onMouseOver={onMouseOver}
onMouseOut={onMouseOut}
preload="none"
>
<source src={mediaURL} type={content.info?.mimetype} />
</video>, "video-container", {}]
} else if (content.msgtype === "m.audio") {
return [<audio controls src={mediaURL} preload="none"/>, "audio-container", {}]
} else if (content.msgtype === "m.file") {
return [
<>
<a
href={mediaURL}
target="_blank"
rel="noopener noreferrer"
download={content.filename ?? content.body}
><DownloadIcon height={32} width={32}/> {content.filename ?? content.body}</a>
</>,
"file-container",
{},
]
}
return [null, "unknown-container", {}]
}
export const MediaMessageBody = ({ event, room }: EventContentProps) => { export const MediaMessageBody = ({ event, room }: EventContentProps) => {
const content = event.content as MediaMessageEventContent 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 let caption = null
if (content.body && content.filename && content.body !== content.filename) { if (content.body && content.filename && content.body !== content.filename) {
caption = <TextMessageBody event={event} room={room} /> caption = <TextMessageBody event={event} room={room} />
} }
const [mediaContent, containerClass, containerStyle] = useMediaContent(content, event.type)
return <> return <>
<div className="media-container" style={style.container}> <div className={`media-container ${containerClass}`} style={containerStyle}>
<img {mediaContent}
loading="lazy"
style={style.media}
src={getMediaURL(content.url ?? content.file?.url)}
alt={content.filename ?? content.body}
onClick={openLightbox}
/>
</div> </div>
{caption} {caption}
</> </>