diff --git a/web/eslint.config.js b/web/eslint.config.js
index 81f1bdf..06e8b7f 100644
--- a/web/eslint.config.js
+++ b/web/eslint.config.js
@@ -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},
diff --git a/web/src/api/media.ts b/web/src/api/media.ts
index 2a53896..0de06ad 100644
--- a/web/src/api/media.ts
+++ b/web/src/api/media.ts
@@ -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 => {
diff --git a/web/src/api/types/mxtypes.ts b/web/src/api/types/mxtypes.ts
index b530bd7..2764f02 100644
--- a/web/src/api/types/mxtypes.ts
+++ b/web/src/api/types/mxtypes.ts
@@ -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 {
diff --git a/web/src/ui/timeline/TimelineEvent.css b/web/src/ui/timeline/TimelineEvent.css
index e543506..3216059 100644
--- a/web/src/ui/timeline/TimelineEvent.css
+++ b/web/src/ui/timeline/TimelineEvent.css
@@ -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%;
+ }
}
diff --git a/web/src/ui/timeline/TimelineEvent.tsx b/web/src/ui/timeline/TimelineEvent.tsx
index 0d23a6c..12ce124 100644
--- a/web/src/ui/timeline/TimelineEvent.tsx
+++ b/web/src/ui/timeline/TimelineEvent.tsx
@@ -41,7 +41,6 @@ function getBodyType(evt: MemDBEvent): React.FunctionComponent.
-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) => {
if ((evt.target as HTMLElement).closest("span[data-mx-spoiler]")?.classList.toggle("spoiler-revealed")) {
@@ -43,26 +44,74 @@ export const TextMessageBody = ({ event }: EventContentProps) => {
return {content.body}
}
+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 [
, "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 | undefined
+ let onMouseOut: React.MouseEventHandler | undefined
+ if (!autoplay && !controls) {
+ onMouseOver = (event: React.MouseEvent) => event.currentTarget.play()
+ onMouseOut = (event: React.MouseEvent) => {
+ event.currentTarget.pause()
+ event.currentTarget.currentTime = 0
+ }
+ }
+ return [, "video-container", {}]
+ } else if (content.msgtype === "m.audio") {
+ return [, "audio-container", {}]
+ } else if (content.msgtype === "m.file") {
+ return [
+ <>
+ {content.filename ?? content.body}
+ >,
+ "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 =
}
+ const [mediaContent, containerClass, containerStyle] = useMediaContent(content, event.type)
return <>
-
-

+
+ {mediaContent}
{caption}
>