web/timeline: use blurhash before image loaded and for spoilers

Signed-off-by: Sumner Evans <me@sumnerevans.com>
This commit is contained in:
Sumner Evans 2024-11-30 18:27:04 -07:00
parent e9f0d7e2a7
commit b31c92def3
No known key found for this signature in database
GPG key ID: 8904527AB50022FD
7 changed files with 101 additions and 10 deletions

View file

@ -1,4 +1,4 @@
#!/bin/bash #!/usr/bin/env bash
cd web > /dev/null cd web > /dev/null
if [[ -f "./node_modules/.bin/eslint" ]]; then if [[ -f "./node_modules/.bin/eslint" ]]; then
ARGS=("$@") ARGS=("$@")

View file

@ -1,4 +1,4 @@
#!/bin/bash #!/usr/bin/env bash
cd web > /dev/null cd web > /dev/null
if [[ -f "./node_modules/.bin/tsc" ]]; then if [[ -f "./node_modules/.bin/tsc" ]]; then
tsc --build --noEmit tsc --build --noEmit

18
web/package-lock.json generated
View file

@ -12,8 +12,10 @@
"@types/react": "npm:types-react@rc", "@types/react": "npm:types-react@rc",
"@types/react-dom": "npm:types-react-dom@rc", "@types/react-dom": "npm:types-react-dom@rc",
"@wailsio/runtime": "^3.0.0-alpha.29", "@wailsio/runtime": "^3.0.0-alpha.29",
"blurhash": "^2.0.5",
"katex": "^0.16.11", "katex": "^0.16.11",
"react": "^19.0.0-rc.1", "react": "^19.0.0-rc.1",
"react-blurhash": "^0.3.0",
"react-dom": "^19.0.0-rc.1", "react-dom": "^19.0.0-rc.1",
"react-spinners": "^0.14.1", "react-spinners": "^0.14.1",
"unhomoglyph": "^1.0.6" "unhomoglyph": "^1.0.6"
@ -2271,6 +2273,12 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/blurhash": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/blurhash/-/blurhash-2.0.5.tgz",
"integrity": "sha512-cRygWd7kGBQO3VEhPiTgq4Wc43ctsM+o46urrmPOiuAe+07fzlSB9OJVdpgDL0jPqXUVQ9ht7aq7kxOeJHRK+w==",
"license": "MIT"
},
"node_modules/brace-expansion": { "node_modules/brace-expansion": {
"version": "1.1.11", "version": "1.1.11",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
@ -4641,6 +4649,16 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/react-blurhash": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/react-blurhash/-/react-blurhash-0.3.0.tgz",
"integrity": "sha512-XlKr4Ns1iYFRnk6DkAblNbAwN/bTJvxTVoxMvmTcURdc5oLoXZwqAF9N3LZUh/HT+QFlq5n6IS6VsDGsviYAiQ==",
"license": "MIT",
"peerDependencies": {
"blurhash": "^2.0.3",
"react": ">=15"
}
},
"node_modules/react-dom": { "node_modules/react-dom": {
"version": "19.0.0-rc.1", "version": "19.0.0-rc.1",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.0.0-rc.1.tgz", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.0.0-rc.1.tgz",

View file

@ -14,8 +14,10 @@
"@types/react": "npm:types-react@rc", "@types/react": "npm:types-react@rc",
"@types/react-dom": "npm:types-react-dom@rc", "@types/react-dom": "npm:types-react-dom@rc",
"@wailsio/runtime": "^3.0.0-alpha.29", "@wailsio/runtime": "^3.0.0-alpha.29",
"blurhash": "^2.0.5",
"katex": "^0.16.11", "katex": "^0.16.11",
"react": "^19.0.0-rc.1", "react": "^19.0.0-rc.1",
"react-blurhash": "^0.3.0",
"react-dom": "^19.0.0-rc.1", "react-dom": "^19.0.0-rc.1",
"react-spinners": "^0.14.1", "react-spinners": "^0.14.1",
"unhomoglyph": "^1.0.6" "unhomoglyph": "^1.0.6"

View file

@ -126,6 +126,11 @@ export interface RelatesTo {
} }
} }
export interface ContentWarning {
type: string
description?: string
}
export interface BaseMessageEventContent { export interface BaseMessageEventContent {
msgtype: string msgtype: string
body: string body: string
@ -133,6 +138,8 @@ export interface BaseMessageEventContent {
format?: "org.matrix.custom.html" format?: "org.matrix.custom.html"
"m.mentions"?: Mentions "m.mentions"?: Mentions
"m.relates_to"?: RelatesTo "m.relates_to"?: RelatesTo
"m.content_warning"?: ContentWarning
"town.robin.msc3725.content_warning"?: ContentWarning
} }
export interface TextMessageEventContent extends BaseMessageEventContent { export interface TextMessageEventContent extends BaseMessageEventContent {
@ -178,6 +185,7 @@ export interface MediaInfo {
"fi.mau.hide_controls"?: boolean "fi.mau.hide_controls"?: boolean
"fi.mau.loop"?: boolean "fi.mau.loop"?: boolean
"xyz.amorgan.blurhash"?: string
} }
export interface LocationMessageEventContent extends BaseMessageEventContent { export interface LocationMessageEventContent extends BaseMessageEventContent {

View file

@ -199,6 +199,21 @@ div.html-body {
} }
div.media-container { div.media-container {
&.image-container {
.placeholder {
position: relative;
.spoiler-indicator {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background-color: rgba(0,0,0,0.3);
padding: 0.5rem;
}
}
}
&.video-container { &.video-container {
width: 100%; width: 100%;
height: 240px; height: 240px;

View file

@ -13,7 +13,8 @@
// //
// 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 { CSSProperties, use } from "react" import React, { CSSProperties, use } from "react"
import { Blurhash } from "react-blurhash"
import { getEncryptedMediaURL, getMediaURL } from "@/api/media.ts" import { getEncryptedMediaURL, getMediaURL } from "@/api/media.ts"
import type { EventType, MediaMessageEventContent } from "@/api/types" import type { EventType, MediaMessageEventContent } from "@/api/types"
import { ImageContainerSize, calculateMediaSize } from "@/util/mediasize.ts" import { ImageContainerSize, calculateMediaSize } from "@/util/mediasize.ts"
@ -23,18 +24,65 @@ import DownloadIcon from "@/icons/download.svg?react"
export const useMediaContent = ( export const useMediaContent = (
content: MediaMessageEventContent, evtType: EventType, containerSize?: ImageContainerSize, content: MediaMessageEventContent, evtType: EventType, containerSize?: ImageContainerSize,
): [React.ReactElement | null, string, CSSProperties] => { ): [React.ReactElement | null, string, CSSProperties] => {
const imgEl = React.useRef<HTMLImageElement>(null)
const [loaded, setLoaded] = React.useState(false)
const onImageLoaded = () => setLoaded(true)
const blurhashEl = React.useRef<Blurhash>(null)
const [spoilerShowing, setSpoilerShowing] = React.useState(false)
React.useEffect(() => {
const imgElCurrent = imgEl.current
if (imgElCurrent) {
imgElCurrent.addEventListener("load", onImageLoaded)
return () => imgElCurrent.removeEventListener("load", onImageLoaded)
}
}, [imgEl])
const mediaURL = content.file?.url ? getEncryptedMediaURL(content.file.url) : getMediaURL(content.url) const mediaURL = content.file?.url ? getEncryptedMediaURL(content.file.url) : getMediaURL(content.url)
const thumbnailURL = content.info?.thumbnail_file?.url const thumbnailURL = content.info?.thumbnail_file?.url
? getEncryptedMediaURL(content.info.thumbnail_file.url) : getMediaURL(content.info?.thumbnail_url) ? getEncryptedMediaURL(content.info.thumbnail_file.url) : getMediaURL(content.info?.thumbnail_url)
if (content.msgtype === "m.image" || evtType === "m.sticker") { if (content.msgtype === "m.image" || evtType === "m.sticker") {
const style = calculateMediaSize(content.info?.w, content.info?.h, containerSize) const style = calculateMediaSize(content.info?.w, content.info?.h, containerSize)
return [<img
loading="lazy" const blurhash = content.info ? content.info["xyz.amorgan.blurhash"] : undefined
style={style.media} const isSpoiler = content["m.content_warning"]?.type === "m.spoiler" ||
src={mediaURL} content["town.robin.msc3725.content_warning"]?.type === "town.robin.msc3725.spoiler"
alt={content.filename ?? content.body} const showPlaceholder = (blurhash && !loaded) || (isSpoiler && !spoilerShowing)
onClick={use(LightboxContext)}
/>, "image-container", style.container] const mediaStyle = Object.assign(
style.media,
showPlaceholder ? { display: "none" } : { display: "inline-block" })
return [
<>
<div
onClick={() => setSpoilerShowing(!spoilerShowing)}
style={!showPlaceholder ? { display: "none" } : {}}
className="placeholder"
>
{blurhash && <Blurhash
ref={blurhashEl}
hash={blurhash}
width={style.container.width}
height={style.container.height}
resolutionX={48}
resolutionY={48}
/>}
{!blurhash && <div style={style.container}>
</div>}
{isSpoiler && !spoilerShowing && <div className="spoiler-indicator">Spoiler</div>}
</div>
<img
ref={imgEl}
style={mediaStyle}
src={mediaURL}
alt={content.filename ?? content.body}
onClick={use(LightboxContext)}
/>
</>
, "image-container", style.container]
} else if (content.msgtype === "m.video") { } else if (content.msgtype === "m.video") {
const autoplay = false const autoplay = false
const controls = !content.info?.["fi.mau.hide_controls"] const controls = !content.info?.["fi.mau.hide_controls"]