mirror of
https://github.com/tulir/gomuks.git
synced 2025-04-19 18:13:41 -05:00
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-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",
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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[] {
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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.",
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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}
|
||||
</>
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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") {
|
||||
|
|
Loading…
Add table
Reference in a new issue