web/timeline: add support for hiding media, blurhashes and spoilers

Closes #533
Closes #522
Fixes #504
This commit is contained in:
Tulir Asokan 2024-12-01 23:38:25 +02:00
parent 09fd60fdfe
commit 42140aa0e0
9 changed files with 180 additions and 8 deletions

18
web/package-lock.json generated
View file

@ -12,8 +12,10 @@
"@types/react": "npm:types-react@rc",
"@types/react-dom": "npm:types-react-dom@rc",
"@wailsio/runtime": "^3.0.0-alpha.29",
"blurhash": "^2.0.5",
"katex": "^0.16.11",
"react": "^19.0.0-rc.1",
"react-blurhash": "^0.3.0",
"react-dom": "^19.0.0-rc.1",
"react-spinners": "^0.14.1",
"unhomoglyph": "^1.0.6"
@ -2271,6 +2273,12 @@
"dev": true,
"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": {
"version": "1.1.11",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
@ -4641,6 +4649,16 @@
"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": {
"version": "19.0.0-rc.1",
"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-dom": "npm:types-react-dom@rc",
"@wailsio/runtime": "^3.0.0-alpha.29",
"blurhash": "^2.0.5",
"katex": "^0.16.11",
"react": "^19.0.0-rc.1",
"react-blurhash": "^0.3.0",
"react-dom": "^19.0.0-rc.1",
"react-spinners": "^0.14.1",
"unhomoglyph": "^1.0.6"

View file

@ -13,9 +13,10 @@
//
// 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 { useMemo, useSyncExternalStore } from "react"
import { useEffect, useMemo, useState, useSyncExternalStore } from "react"
import { CustomEmojiPack } from "@/util/emoji"
import type { EventID, EventType, MemDBEvent, UnknownEventContent } from "../types"
import { Preferences, preferences } from "../types/preferences"
import { StateStore } from "./main.ts"
import { RoomStateStore } from "./room.ts"
@ -72,6 +73,26 @@ export function usePreferences(ss: StateStore, room: RoomStateStore | null) {
useSyncExternalStore(room?.preferenceSub.subscribe ?? noopSubscribe, room?.preferenceSub.getData ?? returnNull)
}
export function usePreference<T extends keyof Preferences>(
ss: StateStore, room: RoomStateStore | null, key: T,
): typeof preferences[T]["defaultValue"] {
const [val, setVal] = useState(
(room ? room.preferences[key] : ss.preferences[key]) ?? preferences[key].defaultValue,
)
useEffect(() => {
const checkChanges = () => {
setVal((room ? room.preferences[key] : ss.preferences[key]) ?? preferences[key].defaultValue)
}
const unsubMain = ss.preferenceSub.subscribe(checkChanges)
const unsubRoom = room?.preferenceSub.subscribe(checkChanges)
return () => {
unsubMain()
unsubRoom?.()
}
}, [ss, room, key])
return val
}
export function useCustomEmojis(
ss: StateStore, room: RoomStateStore,
): CustomEmojiPack[] {

View file

@ -126,6 +126,18 @@ export interface RelatesTo {
}
}
export enum ContentWarningType {
Spoiler = "town.robin.msc3725.spoiler",
NSFW = "town.robin.msc3725.nsfw",
Graphic = "town.robin.msc3725.graphic",
Medical = "town.robin.msc3725.medical",
}
export interface ContentWarning {
type: ContentWarningType
description?: string
}
export interface BaseMessageEventContent {
msgtype: string
body: string
@ -133,6 +145,9 @@ export interface BaseMessageEventContent {
format?: "org.matrix.custom.html"
"m.mentions"?: Mentions
"m.relates_to"?: RelatesTo
"town.robin.msc3725.content_warning"?: ContentWarning
"page.codeberg.everypizza.msc4193.spoiler"?: boolean
"page.codeberg.everypizza.msc4193.spoiler.reason"?: string
}
export interface TextMessageEventContent extends BaseMessageEventContent {
@ -178,6 +193,7 @@ export interface MediaInfo {
"fi.mau.hide_controls"?: boolean
"fi.mau.loop"?: boolean
"xyz.amorgan.blurhash"?: string
}
export interface LocationMessageEventContent extends BaseMessageEventContent {

View file

@ -43,6 +43,12 @@ export const preferences = {
allowedContexts: anyContext,
defaultValue: true,
}),
show_media_previews: new Preference<boolean>({
displayName: "Show image and video previews",
description: "If disabled, images and videos will only be visible after clicking and will not be downloaded automatically.",
allowedContexts: anyContext,
defaultValue: true,
}),
code_block_line_wrap: new Preference<boolean>({
displayName: "Code block line wrap",
description: "Whether to wrap long lines in code blocks instead of scrolling horizontally.",

View file

@ -13,6 +13,8 @@
--visited-link-text-color: var(--link-text-color);
--code-background-color: rgba(0, 0, 0, 0.15);
--media-placeholder-default-background: rgba(0, 0, 0, .1);
--media-placeholder-button-background: rgba(255, 255, 255, .5);
--primary-color: #00c853;
--primary-color-dark: #00b24a;
@ -78,6 +80,8 @@
--link-text-color: #4187eb;
--code-background-color: rgba(255, 255, 255, 0.1);
--media-placeholder-default-background: rgba(255, 255, 255, .1);
--media-placeholder-button-background: rgba(0, 0, 0, .5);
--primary-color: #00b24a;
--primary-color-dark: #00c853;

View file

@ -13,21 +13,89 @@
//
// 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 { MediaMessageEventContent } from "@/api/types"
import { CSSProperties, JSX, use, useReducer } from "react"
import { Blurhash } from "react-blurhash"
import { GridLoader } from "react-spinners"
import { usePreference } from "@/api/statestore"
import { ContentWarningType, MediaMessageEventContent } from "@/api/types"
import { ensureString } from "@/util/validation.ts"
import ClientContext from "../../ClientContext.ts"
import TextMessageBody from "./TextMessageBody.tsx"
import EventContentProps from "./props.ts"
import { useMediaContent } from "./useMediaContent.tsx"
const loaderSize = (style: CSSProperties): number | undefined => {
if (!style.width) {
return
}
const width = +(style.width as string).replace("px", "")
const height = +(style.height as string).replace("px", "")
// GridLoader takes size of individual bubbles for some reason (so need to divide by 3),
// and we want the size to be slightly smaller than the container, so just divide by 5
return Math.min(Math.round(Math.min(width, height) / 5), 30)
}
const switchToTrue = () => true
const MediaMessageBody = ({ event, room, sender }: EventContentProps) => {
const content = event.content as MediaMessageEventContent
let caption = null
if (content.body && content.filename && content.body !== content.filename) {
caption = <TextMessageBody event={event} room={room} sender={sender} />
}
const [mediaContent, containerClass, containerStyle] = useMediaContent(content, event.type)
const client = use(ClientContext)!
const supportsLoadingPlaceholder = event.type === "m.sticker" || content.msgtype === "m.image"
const supportsClickToShow = supportsLoadingPlaceholder || content.msgtype === "m.video"
const showPreviewsByDefault = usePreference(client.store, room, "show_media_previews")
const [loaded, onLoad] = useReducer(switchToTrue, !supportsLoadingPlaceholder)
const [clickedShow, onClickShow] = useReducer(switchToTrue, false)
let contentWarning = content["town.robin.msc3725.content_warning"]
if (content["page.codeberg.everypizza.msc4193.spoiler"]) {
contentWarning = {
type: ContentWarningType.Spoiler,
description: content["page.codeberg.everypizza.msc4193.spoiler.reason"],
}
}
const renderMediaElem = !supportsClickToShow || showPreviewsByDefault || clickedShow
const renderPlaceholderElem = supportsClickToShow && (!renderMediaElem || !!contentWarning || !loaded)
const isLoadingOnlyCover = !loaded && !contentWarning && renderMediaElem
const [mediaContent, containerClass, containerStyle] = useMediaContent(
content, event.type, undefined, onLoad, !clickedShow,
)
let placeholderElem: JSX.Element | null = null
if (renderPlaceholderElem) {
const blurhash = ensureString(
content.info?.["xyz.amorgan.blurhash"] ?? content.info?.thumbnail_info?.["xyz.amorgan.blurhash"],
)
placeholderElem = <div
onClick={onClickShow}
className="placeholder"
>
{(blurhash && containerStyle.width) ? <Blurhash
hash={blurhash}
width={containerStyle.width}
height={containerStyle.height}
resolutionX={48}
resolutionY={48}
/> : <div className="empty-placeholder" style={containerStyle}/>}
{isLoadingOnlyCover
? <div className="placeholder-spinner">
<GridLoader color="var(--primary-color)" size={loaderSize(containerStyle)}/>
</div>
: <div className="placeholder-reason">
{ensureString(contentWarning?.description) || "Show media"}
</div>}
</div>
}
return <>
<div className={`media-container ${containerClass}`} style={containerStyle}>
{mediaContent}
{placeholderElem}
{renderMediaElem ? mediaContent : null}
</div>
{caption}
</>

View file

@ -199,6 +199,37 @@ div.html-body {
}
div.media-container {
&.image-container, &.video-container {
contain: strict;
}
> div.placeholder {
position: relative;
width: 100%;
height: 100%;
> div.empty-placeholder {
background-color: var(--media-placeholder-default-background);
width: 100%;
height: 100%;
}
> div.placeholder-reason, > div.placeholder-spinner {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}
> div.placeholder-reason {
background-color: var(--media-placeholder-button-background);
padding: 0.5rem;
cursor: var(--clickable-cursor);
user-select: none;
border-radius: .25rem;
}
}
&.video-container {
width: 100%;
height: 240px;

View file

@ -13,7 +13,7 @@
//
// 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 { CSSProperties, use } from "react"
import React, { CSSProperties, JSX, use } from "react"
import { getEncryptedMediaURL, getMediaURL } from "@/api/media.ts"
import type { EventType, MediaMessageEventContent } from "@/api/types"
import { ImageContainerSize, calculateMediaSize } from "@/util/mediasize.ts"
@ -21,18 +21,24 @@ import { LightboxContext } from "../../modal/Lightbox.tsx"
import DownloadIcon from "@/icons/download.svg?react"
export const useMediaContent = (
content: MediaMessageEventContent, evtType: EventType, containerSize?: ImageContainerSize,
): [React.ReactElement | null, string, CSSProperties] => {
content: MediaMessageEventContent,
evtType: EventType,
containerSize?: ImageContainerSize,
onLoad?: () => void,
lazyLoad = true,
): [JSX.Element | null, string, CSSProperties] => {
const mediaURL = content.file?.url ? getEncryptedMediaURL(content.file.url) : getMediaURL(content.url)
const thumbnailURL = content.info?.thumbnail_file?.url
? getEncryptedMediaURL(content.info.thumbnail_file.url) : getMediaURL(content.info?.thumbnail_url)
if (content.msgtype === "m.image" || evtType === "m.sticker") {
const style = calculateMediaSize(content.info?.w, content.info?.h, containerSize)
return [<img
loading="lazy"
onLoad={onLoad}
loading={lazyLoad ? "lazy" : "eager"}
style={style.media}
src={mediaURL}
alt={content.filename ?? content.body}
title={content.filename ?? content.body}
onClick={use(LightboxContext)}
/>, "image-container", style.container]
} else if (content.msgtype === "m.video") {