mirror of
https://github.com/tulir/gomuks.git
synced 2025-04-19 18:13:41 -05:00
web/timeline: add support for other file types
This commit is contained in:
parent
11aa2eabf1
commit
6c55f1654c
6 changed files with 125 additions and 41 deletions
|
@ -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},
|
||||||
|
|
|
@ -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 => {
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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%;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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}
|
||||||
</>
|
</>
|
||||||
|
|
Loading…
Add table
Reference in a new issue