forked from Mirrors/gomuks
web/timeline: add support for hiding media, blurhashes and spoilers
Closes #533 Closes #522 Fixes #504
This commit is contained in:
parent
09fd60fdfe
commit
42140aa0e0
9 changed files with 180 additions and 8 deletions
18
web/package-lock.json
generated
18
web/package-lock.json
generated
|
@ -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",
|
||||||
|
|
|
@ -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"
|
||||||
|
|
|
@ -13,9 +13,10 @@
|
||||||
//
|
//
|
||||||
// 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 { useMemo, useSyncExternalStore } from "react"
|
import { useEffect, useMemo, useState, useSyncExternalStore } from "react"
|
||||||
import { CustomEmojiPack } from "@/util/emoji"
|
import { CustomEmojiPack } from "@/util/emoji"
|
||||||
import type { EventID, EventType, MemDBEvent, UnknownEventContent } from "../types"
|
import type { EventID, EventType, MemDBEvent, UnknownEventContent } from "../types"
|
||||||
|
import { Preferences, preferences } from "../types/preferences"
|
||||||
import { StateStore } from "./main.ts"
|
import { StateStore } from "./main.ts"
|
||||||
import { RoomStateStore } from "./room.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)
|
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(
|
export function useCustomEmojis(
|
||||||
ss: StateStore, room: RoomStateStore,
|
ss: StateStore, room: RoomStateStore,
|
||||||
): CustomEmojiPack[] {
|
): CustomEmojiPack[] {
|
||||||
|
|
|
@ -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 {
|
export interface BaseMessageEventContent {
|
||||||
msgtype: string
|
msgtype: string
|
||||||
body: string
|
body: string
|
||||||
|
@ -133,6 +145,9 @@ 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
|
||||||
|
"town.robin.msc3725.content_warning"?: ContentWarning
|
||||||
|
"page.codeberg.everypizza.msc4193.spoiler"?: boolean
|
||||||
|
"page.codeberg.everypizza.msc4193.spoiler.reason"?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TextMessageEventContent extends BaseMessageEventContent {
|
export interface TextMessageEventContent extends BaseMessageEventContent {
|
||||||
|
@ -178,6 +193,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 {
|
||||||
|
|
|
@ -43,6 +43,12 @@ export const preferences = {
|
||||||
allowedContexts: anyContext,
|
allowedContexts: anyContext,
|
||||||
defaultValue: true,
|
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>({
|
code_block_line_wrap: new Preference<boolean>({
|
||||||
displayName: "Code block line wrap",
|
displayName: "Code block line wrap",
|
||||||
description: "Whether to wrap long lines in code blocks instead of scrolling horizontally.",
|
description: "Whether to wrap long lines in code blocks instead of scrolling horizontally.",
|
||||||
|
|
|
@ -13,6 +13,8 @@
|
||||||
--visited-link-text-color: var(--link-text-color);
|
--visited-link-text-color: var(--link-text-color);
|
||||||
|
|
||||||
--code-background-color: rgba(0, 0, 0, 0.15);
|
--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: #00c853;
|
||||||
--primary-color-dark: #00b24a;
|
--primary-color-dark: #00b24a;
|
||||||
|
@ -78,6 +80,8 @@
|
||||||
--link-text-color: #4187eb;
|
--link-text-color: #4187eb;
|
||||||
|
|
||||||
--code-background-color: rgba(255, 255, 255, 0.1);
|
--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: #00b24a;
|
||||||
--primary-color-dark: #00c853;
|
--primary-color-dark: #00c853;
|
||||||
|
|
|
@ -13,21 +13,89 @@
|
||||||
//
|
//
|
||||||
// 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 { 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 TextMessageBody from "./TextMessageBody.tsx"
|
||||||
import EventContentProps from "./props.ts"
|
import EventContentProps from "./props.ts"
|
||||||
import { useMediaContent } from "./useMediaContent.tsx"
|
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 MediaMessageBody = ({ event, room, sender }: EventContentProps) => {
|
||||||
const content = event.content as MediaMessageEventContent
|
const content = event.content as MediaMessageEventContent
|
||||||
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} sender={sender} />
|
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 <>
|
return <>
|
||||||
<div className={`media-container ${containerClass}`} style={containerStyle}>
|
<div className={`media-container ${containerClass}`} style={containerStyle}>
|
||||||
{mediaContent}
|
{placeholderElem}
|
||||||
|
{renderMediaElem ? mediaContent : null}
|
||||||
</div>
|
</div>
|
||||||
{caption}
|
{caption}
|
||||||
</>
|
</>
|
||||||
|
|
|
@ -199,6 +199,37 @@ div.html-body {
|
||||||
}
|
}
|
||||||
|
|
||||||
div.media-container {
|
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 {
|
&.video-container {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 240px;
|
height: 240px;
|
||||||
|
|
|
@ -13,7 +13,7 @@
|
||||||
//
|
//
|
||||||
// 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, JSX, use } from "react"
|
||||||
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"
|
||||||
|
@ -21,18 +21,24 @@ import { LightboxContext } from "../../modal/Lightbox.tsx"
|
||||||
import DownloadIcon from "@/icons/download.svg?react"
|
import DownloadIcon from "@/icons/download.svg?react"
|
||||||
|
|
||||||
export const useMediaContent = (
|
export const useMediaContent = (
|
||||||
content: MediaMessageEventContent, evtType: EventType, containerSize?: ImageContainerSize,
|
content: MediaMessageEventContent,
|
||||||
): [React.ReactElement | null, string, CSSProperties] => {
|
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 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
|
return [<img
|
||||||
loading="lazy"
|
onLoad={onLoad}
|
||||||
|
loading={lazyLoad ? "lazy" : "eager"}
|
||||||
style={style.media}
|
style={style.media}
|
||||||
src={mediaURL}
|
src={mediaURL}
|
||||||
alt={content.filename ?? content.body}
|
alt={content.filename ?? content.body}
|
||||||
|
title={content.filename ?? content.body}
|
||||||
onClick={use(LightboxContext)}
|
onClick={use(LightboxContext)}
|
||||||
/>, "image-container", style.container]
|
/>, "image-container", style.container]
|
||||||
} else if (content.msgtype === "m.video") {
|
} else if (content.msgtype === "m.video") {
|
||||||
|
|
Loading…
Add table
Reference in a new issue