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,
"warnOnUnassignedImports": true,
"pathGroups": [{
"pattern": "@/**",
"group": "parent",
"position": "before",
}, {
"pattern": "*.svg?react",
"patternOptions": {"matchBase": true},
"group": "sibling",
"position": "after",
}, {
"pattern": "@/**",
"group": "parent",
"position": "before",
}, {
"pattern": "*.css",
"patternOptions": {"matchBase": true},

View file

@ -18,7 +18,7 @@ import { UserID } from "./types"
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) {
return undefined
}
@ -26,7 +26,11 @@ export const getMediaURL = (mxc?: string): string | undefined => {
if (!match) {
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 => {

View file

@ -79,22 +79,32 @@ 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
}
file?: EncryptedFile
info?: MediaInfo
}
export interface EncryptedFile {
url: ContentURI
k: string
v: "v2"
ext: true
alg: "A256CTR"
key_ops: string[]
kty: "oct"
}
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 {

View file

@ -199,8 +199,25 @@ div.html-body {
}
div.media-container {
&.video-container {
width: 100%;
height: 240px;
}
> a {
display: flex;
align-items: center;
text-decoration: none;
color: inherit;
}
> img {
max-width: 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) {
case "m.room.message":
case "m.sticker":
if (evt.redacted_by) {
return RedactedBody
}
@ -56,11 +55,16 @@ function getBodyType(evt: MemDBEvent): React.FunctionComponent<EventContentProps
case "m.file":
return MediaMessageBody
case "m.location":
// return LocationMessageBody
// fallthrough
// return LocationMessageBody
// fallthrough
default:
return UnknownMessageBody
}
case "m.sticker":
if (evt.redacted_by) {
return RedactedBody
}
return MediaMessageBody
case "m.room.encrypted":
if (evt.redacted_by) {
return RedactedBody

View file

@ -13,14 +13,15 @@
//
// 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 { use, useMemo } from "react"
import { CSSProperties, use, useMemo } from "react"
import sanitizeHtml from "sanitize-html"
import { getMediaURL } from "@/api/media.ts"
import type { MediaMessageEventContent, MessageEventContent } from "@/api/types"
import { getEncryptedMediaURL, getMediaURL } from "@/api/media.ts"
import type { EventType, 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"
import DownloadIcon from "@/icons/download.svg?react"
const onClickHTML = (evt: React.MouseEvent<HTMLDivElement>) => {
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>
}
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) => {
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} />
}
const [mediaContent, containerClass, containerStyle] = useMediaContent(content, event.type)
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 className={`media-container ${containerClass}`} style={containerStyle}>
{mediaContent}
</div>
{caption}
</>