matrix-art

An image gallery for Matrix
git clone git://archive.git.mtrnord.blog/MTRNord/matrix-art.git
Log | Files | Refs | README | LICENSE

commit e7daa2a0cecae6dfaf019316faec9701de3da7ed
parent 15f27a3d5655eb8308875a83d522d58c3843c919
Author: MTRNord <mtrnord1@gmail.com>
Date:   Fri, 28 Jan 2022 00:10:20 +0100

Fix image uploading with loading animation

Diffstat:
Mcomponents/FrontPageImage.tsx | 16++++++++++++----
Mcomponents/submit_page/main.tsx | 105+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------------
Mpackage-lock.json | 30++++++++++++++++++++++++++++++
Mpackage.json | 1+
Mpages/index.tsx | 2+-
Mpages/profile/[userid].tsx | 2+-
6 files changed, 132 insertions(+), 24 deletions(-)

diff --git a/components/FrontPageImage.tsx b/components/FrontPageImage.tsx @@ -4,10 +4,12 @@ import { Blurhash } from "react-blurhash"; import { ImageEvent, ImageGalleryEvent, MatrixEventBase, MatrixImageEvents } from "../helpers/event_types"; import { constMatrixArtServer } from "../helpers/matrix_client"; import { ClientContext } from "./ClientContext"; +import PropTypes from 'prop-types'; type Props = { event: MatrixImageEvents; imageHeight?: string; + show_nsfw: boolean; }; type State = { @@ -28,6 +30,12 @@ export default class FrontPageImage extends PureComponent<Props, State> { } as State; } + static propTypes = { + event: PropTypes.object, + imageHeight: PropTypes.number, + show_nsfw: PropTypes.bool + }; + async componentDidMount() { // auto-register as a guest if not logged in if (!this.context.client?.accessToken) { @@ -85,8 +93,8 @@ export default class FrontPageImage extends PureComponent<Props, State> { return (possible_html_caption.body && possible_html_caption.mimetype === "text/html") ? possible_html_caption.body : possible_text_caption["m.text"]; })[0]; return event.content['m.image_gallery'].map(image => { - if (image["matrixart.nsfw"]) { - return undefined; + if (image["matrixart.nsfw"] && !this.props.show_nsfw) { + return; } const thumbnail_url = image['m.thumbnail'] ? (image['m.thumbnail'].length > 0 ? image['m.thumbnail'][0].url : image['m.file'].url) : image['m.file'].url; return this.render_image_box(thumbnail_url, event.event_id + image['m.file'].url, event.event_id, caption_text, image['m.image'].height, image['m.image'].width, image["xyz.amorgan.blurhash"]); @@ -95,8 +103,8 @@ export default class FrontPageImage extends PureComponent<Props, State> { render_image(event: ImageEvent) { - if (event.content["matrixart.nsfw"]) { - return undefined; + if (event.content["matrixart.nsfw"] && !this.props.show_nsfw) { + return; } const caption_text = event.content['m.caption'].filter(cap => { const possible_html_caption = (cap as { body: string; mimetype: string; }); diff --git a/components/submit_page/main.tsx b/components/submit_page/main.tsx @@ -1,4 +1,4 @@ -import { createRef, PureComponent, RefObject } from "react"; +import { PureComponent } from "react"; import { DropCallbacks } from "./start"; import PropTypes from 'prop-types'; import { PreviewWithDataFile } from "../../pages/submit"; @@ -8,8 +8,10 @@ import { SearchMedia } from "../../pages/api/submitSearch"; import { withRouter } from "next/router"; import { WithRouterProps } from "next/dist/client/with-router"; import { BlurhashEncoder } from "../../helpers/BlurhashEncoder"; -import { ImageEventContent, MatrixContents, ThumbnailData } from "../../helpers/event_types"; +import { ImageEventContent, ThumbnailData } from "../../helpers/event_types"; import { toast } from "react-toastify"; +// @ts-ignore This has no types +import extractPngChunks from "png-chunks-extract"; type ThumbnailableElement = HTMLImageElement | HTMLVideoElement; type ThumbnailTransmissionData = { @@ -18,6 +20,11 @@ type ThumbnailTransmissionData = { }; const MAX_WIDTH = 800; const MAX_HEIGHT = 600; + +// scraped out of a macOS hidpi (5660ppm) screenshot png +// 5669 px (x-axis) , 5669 px (y-axis) , per metre +const PHYS_HIDPI = [0x00, 0x00, 0x16, 0x25, 0x00, 0x00, 0x16, 0x25, 0x01]; + // Minimum size for image files before we generate a thumbnail for them. const IMAGE_SIZE_THRESHOLD_THUMBNAIL = 1 << 15; // 32KB // Minimum size improvement for image thumbnails, if both are not met then don't bother uploading thumbnail. @@ -36,7 +43,6 @@ type State = { [key: string]: any; }; class MainSubmissionForm extends PureComponent<Props, State> { - private image_refs: RefObject<HTMLImageElement>[] = []; declare context: React.ContextType<typeof ClientContext>; constructor(props: Props) { @@ -95,16 +101,12 @@ class MainSubmissionForm extends PureComponent<Props, State> { return classes_base.join(" "); }; - if (!this.image_refs[index]) { - this.image_refs[index] = createRef(); - } - // TODO FIXME do make this work with keyboard presses! /* eslint-disable jsx-a11y/click-events-have-key-events */ return ( <div aria-label={file.name} className={classes.bind(this)()} onClick={setIndex} style={{ height: "144px" }} key={file.name} role="radio" tabIndex={index} aria-checked={this.state.currentFileIndex == index ? true : false}> - <img alt={file.name} ref={this.image_refs[index]} className="h-full w-full object-cover align-middle aspect-video" src={file.preview_url} /> + <img alt={file.name} className="h-full w-full object-cover align-middle aspect-video" src={file.preview_url} /> </div> ); /* eslint-enable jsx-a11y/click-events-have-key-events */ @@ -167,9 +169,10 @@ class MainSubmissionForm extends PureComponent<Props, State> { } } + const image_infos = await this.getImageInfos(); const ids = await this.doUpload(); - const thumbnails = await this.generateThumbnailsAndUpload(); + const thumbnails = await this.generateThumbnailsAndUpload(image_infos); // Handle uploads for (const index of range) { @@ -200,8 +203,8 @@ class MainSubmissionForm extends PureComponent<Props, State> { size: file.size }, "m.image": { - height: this.image_refs[index].current?.naturalHeight!, - width: this.image_refs[index].current?.naturalWidth!, + height: image_infos[index].height, + width: image_infos[index].width, }, "matrixart.description": this.state[description], "matrixart.nsfw": this.state[nsfw] === "yes" ? true : false, @@ -231,7 +234,75 @@ class MainSubmissionForm extends PureComponent<Props, State> { this.setState({ submit_in_process: false }); } - // THis is aken from matrix-react-sdk commit efa1667d7e9de9e429a72396a5105d0219006db2 + /** + * Read the file as an ArrayBuffer. + * @param {File} file The file to read + * @return {Promise} A promise that resolves with an ArrayBuffer when the file + * is read. + */ + private async readFileAsArrayBuffer(file: File | Blob): Promise<ArrayBuffer> { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.addEventListener("load", (e: ProgressEvent<FileReader>) => { + resolve(e.target?.result as ArrayBuffer); + }); + reader.addEventListener("error", (e) => { + reject(e); + }); + reader.readAsArrayBuffer(file); + }); + } + + private async getImageInfos() { + const image_infos = []; + const range = [...Array(this.props.files.length).keys()]; // eslint-disable-line unicorn/new-for-builtins + for (const index of range) { + const imageFile = this.props.files[index]; + // Load the file into an html element + const img = document.createElement("img"); + const objectUrl = URL.createObjectURL(imageFile); + const imgPromise = new Promise((resolve, reject) => { + img.addEventListener("load", () => { + URL.revokeObjectURL(objectUrl); + resolve(img); + }); + img.addEventListener("error", (e) => { + reject(e); + }); + }); + img.src = objectUrl; + + // check for hi-dpi PNGs and fudge display resolution as needed. + // this is mainly needed for macOS screencaps + let parsePromise; + if (imageFile.type === "image/png") { + // in practice macOS happens to order the chunks so they fall in + // the first 0x1000 bytes (thanks to a massive ICC header). + // Thus we could slice the file down to only sniff the first 0x1000 + // bytes (but this makes extractPngChunks choke on the corrupt file) + const headers = imageFile; //.slice(0, 0x1000); + parsePromise = this.readFileAsArrayBuffer(headers).then(arrayBuffer => { + const buffer = new Uint8Array(arrayBuffer); + const chunks = extractPngChunks(buffer); + for (const chunk of chunks) { + if (chunk.name === 'pHYs') { + if (chunk.data.byteLength !== PHYS_HIDPI.length) return; + return chunk.data.every((val: number, i: number) => val === PHYS_HIDPI[i]); + } + } + return false; + }); + } + + const [hidpi] = await Promise.all([parsePromise, imgPromise]); + const width = hidpi ? (img.width >> 1) : img.width; + const height = hidpi ? (img.height >> 1) : img.height; + image_infos.push({ width, height, img }); + } + return image_infos; + } + + // This is taken from matrix-react-sdk commit efa1667d7e9de9e429a72396a5105d0219006db2 private async createThumbnail( element: ThumbnailableElement, inputWidth: number, @@ -300,17 +371,16 @@ class MainSubmissionForm extends PureComponent<Props, State> { }; } - private async generateThumbnailsAndUpload(): Promise<{ index: number; meta: ThumbnailData; }[]> { + private async generateThumbnailsAndUpload(image_infos: { width: number; height: number; img: HTMLImageElement; }[]): Promise<{ index: number; meta: ThumbnailData; }[]> { const thumbnails = []; if (!this.context.client.isGuest) { const range = [...Array(this.props.files.length).keys()]; // eslint-disable-line unicorn/new-for-builtins for (const index of range) { const file = this.props.files[index]; - const image = this.image_refs[index]; const thumbnail_data = await this.createThumbnail( - image.current!, - image.current!.naturalWidth, - image.current!.naturalHeight, + image_infos[index].img, + image_infos[index].width, + image_infos[index].height, file.type ); if (!thumbnail_data) { @@ -469,7 +539,6 @@ class MainSubmissionForm extends PureComponent<Props, State> { } } - MainSubmissionForm.contextType = ClientContext; // @ts-ignore Typescript is wrong diff --git a/package-lock.json b/package-lock.json @@ -13,6 +13,7 @@ "meilisearch": "0.24.0", "next": "12.0.9", "node-localstorage": "2.2.1", + "png-chunks-extract": "^1.0.0", "prop-types": "15.8.1", "react": "17.0.2", "react-blurhash": "0.1.3", @@ -2389,6 +2390,14 @@ "node": ">=10" } }, + "node_modules/crc-32": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-0.3.0.tgz", + "integrity": "sha1-aj02h/W67EH36bmf4ZU6Ll0Zd14=", + "engines": { + "node": ">=0.8" + } + }, "node_modules/cross-env": { "version": "7.0.3", "resolved": "https://registry.npmjs.org/cross-env/-/cross-env-7.0.3.tgz", @@ -5765,6 +5774,14 @@ "node": ">=4" } }, + "node_modules/png-chunks-extract": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/png-chunks-extract/-/png-chunks-extract-1.0.0.tgz", + "integrity": "sha1-+tSpBeZmUhlzUcZeNbksZDEeRy0=", + "dependencies": { + "crc-32": "^0.3.0" + } + }, "node_modules/pngjs": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz", @@ -9236,6 +9253,11 @@ "yaml": "^1.10.0" } }, + "crc-32": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-0.3.0.tgz", + "integrity": "sha1-aj02h/W67EH36bmf4ZU6Ll0Zd14=" + }, "cross-env": { "version": "7.0.3", "resolved": "https://registry.npmjs.org/cross-env/-/cross-env-7.0.3.tgz", @@ -11781,6 +11803,14 @@ "integrity": "sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==", "dev": true }, + "png-chunks-extract": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/png-chunks-extract/-/png-chunks-extract-1.0.0.tgz", + "integrity": "sha1-+tSpBeZmUhlzUcZeNbksZDEeRy0=", + "requires": { + "crc-32": "^0.3.0" + } + }, "pngjs": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz", diff --git a/package.json b/package.json @@ -24,6 +24,7 @@ "meilisearch": "0.24.0", "next": "12.0.9", "node-localstorage": "2.2.1", + "png-chunks-extract": "^1.0.0", "prop-types": "15.8.1", "react": "17.0.2", "react-blurhash": "0.1.3", diff --git a/pages/index.tsx b/pages/index.tsx @@ -110,7 +110,7 @@ class Home extends PureComponent<Props, State>{ </div> <div className='m-10'> <ul className='flex flex-wrap gap-1'> - {image_events.map(event => <FrontPageImage event={event} key={(event as MatrixEventBase).event_id} />)} + {image_events.map(event => <FrontPageImage show_nsfw={false} event={event} key={(event as MatrixEventBase).event_id} />)} <li className='grow-[10]'></li> </ul> </div> diff --git a/pages/profile/[userid].tsx b/pages/profile/[userid].tsx @@ -211,7 +211,7 @@ class Profile extends PureComponent<Props, State> { </div> </div> <div> - <ul className='flex flex-wrap gap-1'>{image_events.map(event => <FrontPageImage event={event} imageHeight="286px" key={(event as MatrixEventBase).event_id} />)} + <ul className='flex flex-wrap gap-1'>{image_events.map(event => <FrontPageImage show_nsfw={true} event={event} imageHeight="286px" key={(event as MatrixEventBase).event_id} />)} <li className='grow-[10]'></li> </ul> </div>