diff --git a/web/package-lock.json b/web/package-lock.json index c00e5e9..547cf8a 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -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", diff --git a/web/package.json b/web/package.json index b21767b..4cc2c8d 100644 --- a/web/package.json +++ b/web/package.json @@ -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" diff --git a/web/src/api/statestore/hooks.ts b/web/src/api/statestore/hooks.ts index 46de365..3de9ab5 100644 --- a/web/src/api/statestore/hooks.ts +++ b/web/src/api/statestore/hooks.ts @@ -13,9 +13,10 @@ // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -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( + 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[] { diff --git a/web/src/api/types/mxtypes.ts b/web/src/api/types/mxtypes.ts index 97f1893..f22c47e 100644 --- a/web/src/api/types/mxtypes.ts +++ b/web/src/api/types/mxtypes.ts @@ -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 { diff --git a/web/src/api/types/preferences/preferences.ts b/web/src/api/types/preferences/preferences.ts index 1404667..34b8e3c 100644 --- a/web/src/api/types/preferences/preferences.ts +++ b/web/src/api/types/preferences/preferences.ts @@ -43,6 +43,12 @@ export const preferences = { allowedContexts: anyContext, defaultValue: true, }), + show_media_previews: new Preference({ + 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({ displayName: "Code block line wrap", description: "Whether to wrap long lines in code blocks instead of scrolling horizontally.", diff --git a/web/src/index.css b/web/src/index.css index 56c58fd..fa76316 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -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; diff --git a/web/src/ui/timeline/content/MediaMessageBody.tsx b/web/src/ui/timeline/content/MediaMessageBody.tsx index 712f320..235ddce 100644 --- a/web/src/ui/timeline/content/MediaMessageBody.tsx +++ b/web/src/ui/timeline/content/MediaMessageBody.tsx @@ -13,21 +13,89 @@ // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -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 = } - 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 =
+ {(blurhash && containerStyle.width) ? :
} + {isLoadingOnlyCover + ?
+ +
+ :
+ {ensureString(contentWarning?.description) || "Show media"} +
} +
+ } + return <>
- {mediaContent} + {placeholderElem} + {renderMediaElem ? mediaContent : null}
{caption} diff --git a/web/src/ui/timeline/content/index.css b/web/src/ui/timeline/content/index.css index 75df96e..1c3c6b6 100644 --- a/web/src/ui/timeline/content/index.css +++ b/web/src/ui/timeline/content/index.css @@ -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; diff --git a/web/src/ui/timeline/content/useMediaContent.tsx b/web/src/ui/timeline/content/useMediaContent.tsx index 90cc573..8516e57 100644 --- a/web/src/ui/timeline/content/useMediaContent.tsx +++ b/web/src/ui/timeline/content/useMediaContent.tsx @@ -13,7 +13,7 @@ // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -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 [{content.filename, "image-container", style.container] } else if (content.msgtype === "m.video") {