matrix-art

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

commit 390bda5be0d3f28204c27c10783d57471a5a7b1b
parent 489d197e99f3e274b83762a8581b914ef2b223eb
Author: MTRNord <mtrnord1@gmail.com>
Date:   Thu, 27 Jan 2022 22:25:37 +0100

Add initial support for file uploads

Diffstat:
M.env.local.example | 7+++++--
M.gitignore | 2++
Mcomponents/FrontPageImage.tsx | 26+++++++++++++++-----------
Mcomponents/submit_page/main.tsx | 231++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------
Ahelpers/BlurhashEncoder.ts | 59+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mhelpers/event_types.ts | 23+++++++++++++++++------
Mhelpers/matrix_client.ts | 45++++++++++++++++++++++++++++++++++++---------
Ahelpers/utils.ts | 19+++++++++++++++++++
Ahelpers/workers/blurhash.worker.ts | 36++++++++++++++++++++++++++++++++++++
Mpackage-lock.json | 13+++++++++++++
Mpackage.json | 1+
Apages/api/directory.ts | 110+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Dpages/api/directory.tsx | 108-------------------------------------------------------------------------------
Apages/api/submitSearch.ts | 60++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpages/index.tsx | 54++++++++++++++++++++++++++++++++++--------------------
Mpages/post/[id].tsx | 34++++++++++++++++++++--------------
Mpages/profile/[userid].tsx | 2+-
17 files changed, 641 insertions(+), 189 deletions(-)

diff --git a/.env.local.example b/.env.local.example @@ -1 +1,4 @@ -NEXT_PUBLIC_DEFAULT_SERVER_URL=https://matrix.something.com -\ No newline at end of file +NEXT_PUBLIC_DEFAULT_SERVER_URL=https://matrix.something.com +NEXT_PUBLIC_SEARCH_URL=http://127.0.0.1:7700 +MEILI_MASTER_KEY=xxx +MEILI_SEARCH_KEY=xxx +\ No newline at end of file diff --git a/.gitignore b/.gitignore @@ -44,3 +44,4 @@ yarn-error.log* .env.local test-results/ playwright-report/ +/meilifiles +\ No newline at end of file diff --git a/components/FrontPageImage.tsx b/components/FrontPageImage.tsx @@ -84,21 +84,25 @@ export default class FrontPageImage extends PureComponent<Props, State> { caption_text = (caption[0] as { body: string; mimetype: string; }).body; } return event.content['m.image_gallery'].map(image => { - return this.render_image_box(image['m.thumbnail'][0].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"]); + 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"]); }); } render_image(event: ImageEvent) { - const caption = event.content['m.caption'].filter((cap) => { + let caption_text = event.content['m.caption'].filter(cap => { + const possible_html_caption = (cap as { body: string; mimetype: string; }); - return possible_html_caption.body !== undefined && possible_html_caption.mimetype === "text/html"; - }); - let caption_text = ""; - if (caption.length > 0) { - caption_text = (caption[0] as { body: string; mimetype: string; }).body; - } - return this.render_image_box(event.content['m.thumbnail'][0].url, event.event_id, event.event_id, caption_text, event.content['m.image'].height, event.content['m.image'].width, event.content["xyz.amorgan.blurhash"]); + const possible_text_caption = (cap as { "m.text": string; }); + return (possible_html_caption.body && possible_html_caption.mimetype === "text/html") || possible_text_caption["m.text"]; + }).map(cap => { + const possible_html_caption = (cap as { body: string; mimetype: string; }); + const possible_text_caption = (cap as { "m.text": string; }); + return (possible_html_caption.body && possible_html_caption.mimetype === "text/html") ? possible_html_caption.body : possible_text_caption["m.text"]; + })[0]; + const thumbnail_url = event.content['m.thumbnail'] ? (event.content['m.thumbnail'].length > 0 ? event.content['m.thumbnail'][0].url : event.content['m.file'].url) : event.content['m.file'].url; + return this.render_image_box(thumbnail_url, event.event_id, event.event_id, caption_text, event.content['m.image'].height, event.content['m.image'].width, event.content["xyz.amorgan.blurhash"]); } render_image_box(thumbnail_url: string, id: string, post_id: string, caption: string, h: number, w: number, blurhash?: string) { @@ -136,10 +140,10 @@ FrontPageImage.contextType = ClientContext; // TODO also render the edits properly later on export function isImageGalleryEvent(event: MatrixImageEvents): event is ImageGalleryEvent { - return event.type === "m.image_gallery" && event.redacted_because === undefined; + return event.type === "m.image_gallery" && !event.unsigned?.redacted_because; } export function isImageEvent(event: MatrixImageEvents): event is ImageEvent { - return event.type === "m.image" && event.redacted_because === undefined; + return event.type === "m.image" && !event.unsigned?.redacted_because; }; \ No newline at end of file diff --git a/components/submit_page/main.tsx b/components/submit_page/main.tsx @@ -1,11 +1,29 @@ -import { PureComponent } from "react"; +import { createRef, PureComponent, RefObject } from "react"; import { DropCallbacks } from "./start"; import PropTypes from 'prop-types'; import { PreviewWithDataFile } from "../../pages/submit"; import { ClientContext } from "../ClientContext"; import Dropzone from "react-dropzone"; +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"; -interface Props extends DropCallbacks { +type ThumbnailableElement = HTMLImageElement | HTMLVideoElement; +type ThumbnailTransmissionData = { + thumbnail_meta: ThumbnailData; + thumbnail: Blob; +}; +const MAX_WIDTH = 800; +const MAX_HEIGHT = 600; +// 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. +const IMAGE_THUMBNAIL_MIN_REDUCTION_SIZE = 1 << 16; // 1MB +const IMAGE_THUMBNAIL_MIN_REDUCTION_PERCENT = 0.1; // 10% + +interface Props extends DropCallbacks, WithRouterProps { files: PreviewWithDataFile[]; }; @@ -16,6 +34,7 @@ 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) { @@ -72,12 +91,17 @@ class MainSubmissionForm extends PureComponent<Props, State> { } return classes_base.join(" "); }; - // TODO FIXME do make this work with keyboard presses! + 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} className="h-full w-full object-cover align-middle aspect-video" src={file.preview_url} /> + <img alt={file.name} ref={this.image_refs[index]} 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 */ @@ -109,26 +133,196 @@ class MainSubmissionForm extends PureComponent<Props, State> { async handleSubmit(event: { preventDefault: () => void; }) { const range = [...Array(this.props.files.length).keys()]; // eslint-disable-line unicorn/new-for-builtins + const posts_for_search: SearchMedia[] = []; + + if (this.context.client.isGuest) { + return; + } + + // If any image is invalid do exit submit for now. + // TODO show an error for (const index of range) { + const valid = `${index}_valid`; + if (!valid) { + return; + } + } + + const ids = await this.doUpload(); + + const thumbnails = await this.generateThumbnailsAndUpload(); + + // Handle uploads + for (const index of range) { + console.log(index); + console.log(this.context.client.profileRoomId); const title = `${index}_title`; - console.log(`${index}: ${this.state[title]}`); const description = `${index}_description`; - console.log(`${index}: ${this.state[description]}`); const tags = `${index}_tags`; - console.log(`${index}: ${this.state[tags]}`); const license = `${index}_license`; - console.log(`${index}: ${this.state[license]}`); const nsfw = `${index}_nsfw`; - console.log(`${index}: ${this.state[nsfw]}`); + const file = this.props.files[index]; + + if (!this.context.client.profileRoomId) { + return; + } + + const event = { + "m.text": this.state[title], + "m.caption": [{ + "m.text": this.state[title] + }], + "m.file": { + mimetype: file.type, + name: file.name, + url: ids[index].url, + size: file.size + }, + "m.image": { + height: this.image_refs[index].current?.naturalHeight!, + width: this.image_refs[index].current?.naturalWidth!, + }, + "matrixart.description": this.state[description], + "matrixart.nsfw": this.state[nsfw] === "yes" ? true : false, + "matrixart.license": this.state[license], + "matrixart.tags": this.state[tags].split(",").map((x: string) => x.trimStart().trimEnd()), + } as unknown as ImageEventContent; + const thumbnailData = thumbnails.find(item => item.index == index); + event["m.thumbnail"] = thumbnailData?.meta["m.thumbnail"]; + event["xyz.amorgan.blurhash"] = thumbnailData?.meta["xyz.amorgan.blurhash"]!; + + const event_id = await this.context.client.sendEvent(this.context.client.profileRoomId, 'm.image', event); + + posts_for_search.push({ + mxc_url: ids[index].url, + event_id: event_id, + title: this.state[title], + description: this.state[description], + tags: this.state[tags].trimStart().trimEnd(), + nsfw: this.state[nsfw] === "yes" ? "true" : "false", + license: this.state[license], + sender: this.context.client.userId! + }); + } + const token = await this.context.client.getOpenidToken(); + await fetch("/api/submitSearch", { method: "POST", body: JSON.stringify({ access_token: token, user_id: this.context.client.userId, docs: posts_for_search }) }); + await this.props.router.replace("/"); + } + + // THis is aken from matrix-react-sdk commit efa1667d7e9de9e429a72396a5105d0219006db2 + private async createThumbnail( + element: ThumbnailableElement, + inputWidth: number, + inputHeight: number, + mimeType: string + ): Promise<ThumbnailTransmissionData | undefined> { + let targetWidth = inputWidth; + let targetHeight = inputHeight; + if (targetHeight > MAX_HEIGHT) { + targetWidth = Math.floor(targetWidth * (MAX_HEIGHT / targetHeight)); + targetHeight = MAX_HEIGHT; + } + if (targetWidth > MAX_WIDTH) { + targetHeight = Math.floor(targetHeight * (MAX_WIDTH / targetWidth)); + targetWidth = MAX_WIDTH; + } + + let canvas: HTMLCanvasElement | OffscreenCanvas; + let context: OffscreenCanvasRenderingContext2D | CanvasRenderingContext2D | null; + try { + canvas = new window.OffscreenCanvas(targetWidth, targetHeight); + context = canvas.getContext("2d"); + } catch { + // Fallback support for other browsers (Safari and Firefox for now) + canvas = document.createElement("canvas"); + (canvas as HTMLCanvasElement).width = targetWidth; + (canvas as HTMLCanvasElement).height = targetHeight; + context = canvas.getContext("2d"); + } + + if (!context) { + return; + } + context?.drawImage(element, 0, 0, targetWidth, targetHeight); + + let thumbnailPromise: Promise<Blob | null>; + + if (window.OffscreenCanvas) { + thumbnailPromise = (canvas as OffscreenCanvas).convertToBlob({ type: mimeType }); + } else { + thumbnailPromise = new Promise<Blob | null>(resolve => (canvas as HTMLCanvasElement).toBlob(resolve, mimeType)); + } + + const imageData = context.getImageData(0, 0, targetWidth, targetHeight); + // thumbnailPromise and blurhash promise are being awaited concurrently + const blurhash = await BlurhashEncoder.instance.getBlurhash(imageData); + const thumbnail = await thumbnailPromise; + if (!thumbnail) { + return; + } + + return { + thumbnail_meta: { + "m.thumbnail": [ + { + width: targetWidth, + height: targetHeight, + mimetype: thumbnail.type, + size: thumbnail.size, + url: "" + } + ], + "xyz.amorgan.blurhash": blurhash, + }, + thumbnail, + }; + } + + private async generateThumbnailsAndUpload(): 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, + file.type + ); + if (!thumbnail_data) { + // TODO this causes issues + continue; + } + + // we do all sizing checks here because we still rely on thumbnail generation for making a blurhash from. + const sizeDifference = file.size - thumbnail_data.thumbnail_meta["m.thumbnail"]![0].size; + if ( + file.size <= IMAGE_SIZE_THRESHOLD_THUMBNAIL || // image is small enough already + (sizeDifference <= IMAGE_THUMBNAIL_MIN_REDUCTION_SIZE && // thumbnail is not sufficiently smaller than original + sizeDifference <= (file.size * IMAGE_THUMBNAIL_MIN_REDUCTION_PERCENT)) + ) { + delete thumbnail_data.thumbnail_meta["m.thumbnail"]; + thumbnails.push({ index: index, meta: thumbnail_data.thumbnail_meta }); + } + + const result = await this.context.client.uploadFile(thumbnail_data.thumbnail); + thumbnail_data.thumbnail_meta["m.thumbnail"]![0].url = result; + thumbnails.push({ index: index, meta: thumbnail_data.thumbnail_meta }); + } } + return thumbnails; } private async doUpload() { const urls = []; if (!this.context.client.isGuest) { - for (const file of this.props.files) { - const result = this.context.client.uploadFile(file); - urls.push({ file_name: file.name, url: result }); + 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 result = await this.context.client.uploadFile(file); + urls.push({ index: index, url: result }); } } return urls; @@ -190,8 +384,8 @@ class MainSubmissionForm extends PureComponent<Props, State> { <label className="inner-flex flex-col"> <span className="text-xl text-gray-900 dark:text-gray-200 font-bold">License</span> - <select required name="license" value={this.state[`${this.state.currentFileIndex}_license`] || ""} placeholder="Enter tags (Confirm by pressing enter)" className="min-w-full placeholder:text-gray-900 text-gray-900 rounded py-1.5 px-2" onChange={this.handleInputChange.bind(this)}> - <option value="" disabled selected>Select an Creative Commons License</option> + <select defaultValue="" required name="license" value={this.state[`${this.state.currentFileIndex}_license`] || ""} placeholder="Enter tags (Confirm by pressing enter)" className="min-w-full placeholder:text-gray-900 text-gray-900 rounded py-1.5 px-2" onChange={this.handleInputChange.bind(this)}> + <option value="" disabled>Select an Creative Commons License</option> <option value="cc-by-4.0">Attribution 4.0 International (CC BY 4.0)</option> <option value="cc-by-sa-4.0">Attribution-ShareAlike 4.0 International (CC BY-SA 4.0)</option> <option value="cc-by-nc-4.0">Attribution-NonCommercial 4.0 International (CC BY-NC 4.0)</option> @@ -206,8 +400,8 @@ class MainSubmissionForm extends PureComponent<Props, State> { <label className="inner-flex flex-col"> <span className="text-xl text-gray-900 dark:text-gray-200 font-bold">Mature/NSFW Content?</span> - <select required name="nsfw" value={this.state[`${this.state.currentFileIndex}_nsfw`] || ""} placeholder="Enter tags (Confirm by pressing enter)" className="min-w-full placeholder:text-gray-900 text-gray-900 rounded py-1.5 px-2" onChange={this.handleInputChange.bind(this)}> - <option value="" disabled selected>Select Yes or No</option> + <select defaultValue="" required name="nsfw" value={this.state[`${this.state.currentFileIndex}_nsfw`] || ""} placeholder="Enter tags (Confirm by pressing enter)" className="min-w-full placeholder:text-gray-900 text-gray-900 rounded py-1.5 px-2" onChange={this.handleInputChange.bind(this)}> + <option value="" disabled>Select Yes or No</option> <option value="no">No</option> <option value="yes">Yes</option> </select> @@ -248,4 +442,5 @@ class MainSubmissionForm extends PureComponent<Props, State> { MainSubmissionForm.contextType = ClientContext; -export default MainSubmissionForm; -\ No newline at end of file +// @ts-ignore Typescript is wrong +export default withRouter(MainSubmissionForm); +\ No newline at end of file diff --git a/helpers/BlurhashEncoder.ts b/helpers/BlurhashEncoder.ts @@ -0,0 +1,58 @@ +/* +Copyright 2021 The Matrix.org Foundation C.I.C. +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { defer, IDeferred } from "./utils"; + +interface IBlurhashWorkerResponse { + seq: number; + blurhash: string; +} + +export class BlurhashEncoder { + private static internalInstance = new BlurhashEncoder(); + + public static get instance(): BlurhashEncoder { + return BlurhashEncoder.internalInstance; + } + + private readonly worker?: Worker; + private seq = 0; + private pendingDeferredMap = new Map<number, IDeferred<string>>(); + + constructor() { + if (typeof Worker !== 'undefined') { + console.log("created Worker!"); + this.worker = new Worker(new URL('./workers/blurhash.worker.ts', import.meta.url)); + } + this.worker?.addEventListener("message", this.onMessage.bind(this)); + } + + private onMessage = (ev: MessageEvent<IBlurhashWorkerResponse>) => { + const { seq, blurhash } = ev.data; + const deferred = this.pendingDeferredMap.get(seq); + if (deferred) { + this.pendingDeferredMap.delete(seq); + deferred.resolve(blurhash); + } + }; + + public getBlurhash(imageData: ImageData): Promise<string> { + const seq = this.seq++; + const deferred = defer<string>(); + this.pendingDeferredMap.set(seq, deferred); + console.log("Making blurhash"); + this.worker?.postMessage({ seq, imageData }); + console.log("returning blurhash promise"); + return deferred.promise; + } +} +\ No newline at end of file diff --git a/helpers/event_types.ts b/helpers/event_types.ts @@ -3,8 +3,10 @@ export type MatrixEventBase = { event_id: string; room_id: string; sender: string; - redacted_because?: any; origin_server_ts: number; + unsigned?: { + redacted_because?: any; + }; }; export type MatrixStateEventBase = MatrixEventBase & { @@ -29,14 +31,21 @@ export type ImageFields = { height: number; }; -export type ImageEventContent = { +export type ThumbnailData = { + "m.thumbnail"?: ThumbnailFileEvent[]; + "xyz.amorgan.blurhash": string; +}; + +export type ImageEventContent = ThumbnailData & { // TODO maybe not correct as it may be formatted? "m.text": string; "m.file": FileEvent; "m.image": ImageFields; - "m.thumbnail": ThumbnailFileEvent[]; "matrixart.tags": string[]; - "xyz.amorgan.blurhash": string; + // TODO check if correct + "matrixart.description": string; + "matrixart.nsfw": boolean; + "matrixart.license": string; }; export type MessageAlike = { "m.text": string; } | { body: string; mimetype: string; }; @@ -88,4 +97,6 @@ export type MatrixArtProfile = MatrixStateEventBase & { }; export type MatrixImageEvents = ImageEvent | ImageGalleryEvent; -export type MatrixEvent = MatrixImageEvents | BannerEvent | MatrixArtProfile; -\ No newline at end of file +export type MatrixEvent = MatrixImageEvents | BannerEvent | MatrixArtProfile; + +export type MatrixContents = MatrixArtProfileContent | BannerEventContent | ImageGalleryContent | ImageEventContentWithCaption | ImageEventContent; +\ No newline at end of file diff --git a/helpers/matrix_client.ts b/helpers/matrix_client.ts @@ -1,17 +1,18 @@ // This file is based on https://github.com/matrix-org/cerulean/blob/1499ca2b80d3a2cc090f75636b1c4db138729b59/src/Client.js -import { MatrixEvent } from './event_types'; +import { MatrixContents, MatrixEvent } from './event_types'; import Storage from './storage'; export const constMatrixArtServer = process.env.NEXT_PUBLIC_DEFAULT_SERVER_URL || "https://matrix.art.midnightthoughts.space"; export default class MatrixClient { - private joinedRooms: Map<any, any>; - private userProfileCache: Map<any, any>; + private joinedRooms: Map<string, string>; + private userProfileCache: Map<string, any>; private storage!: Storage; private serverUrl?: string; private _userId?: string; private _accessToken?: string; private _isGuest?: boolean; + private _profileRoomId?: string; private serverName?: string; get userId(): string | undefined { return this._userId; @@ -23,6 +24,12 @@ export default class MatrixClient { return this._isGuest; } + get profileRoomId(): string | undefined { + console.log(this.joinedRooms); + console.log(this._profileRoomId); + return this.joinedRooms.get(`#${this.userId}`) || this._profileRoomId; + } + constructor(storage: Storage) { this.joinedRooms = new Map(); // room alias -> room ID this.userProfileCache = new Map(); // user_id -> {display_name; avatar;} @@ -35,6 +42,7 @@ export default class MatrixClient { this._accessToken = storage.getItem("accessToken"); this._isGuest = (this.userId || "").indexOf("@matrix_art_guest_") === 0; this.serverName = storage.getItem("serverName"); + this._profileRoomId = storage.getItem("profileRoomId"); } private saveAuthState() { @@ -47,7 +55,7 @@ export default class MatrixClient { this.storage.setOrDelete("serverName", this.serverName); } - generateToken(len: number) { + private generateToken(len: number) { var arr = new Uint8Array(len / 2); if (typeof window !== "undefined") { window.crypto.getRandomValues(arr); @@ -216,6 +224,9 @@ export default class MatrixClient { headers: { Authorization: `Bearer ${this.accessToken}` }, } ); + if (isMyself) { + this.storage.setOrDelete("profileRoomId", data.room_id); + } this.joinedRooms.set(roomAlias, data.room_id); return data.room_id; } catch (error) { @@ -248,6 +259,8 @@ export default class MatrixClient { } ); this.joinedRooms.set(roomAlias, data.room_id); + + this.storage.setOrDelete("profileRoomId", data.room_id); return data.room_id; } else { throw error; @@ -279,8 +292,23 @@ export default class MatrixClient { return `${constMatrixArtServer}/_matrix/media/r0/thumbnail/${mxcUri.split("mxc://")[1]}?method=${encodeURIComponent(method)}&width=${encodeURIComponent(width)}&height=${encodeURIComponent(height)}`; } - async uploadFile(file: File): Promise<string> { - const fileName = file.name; + async sendEvent(roomId: string, event_type: string, content: MatrixContents): Promise<string> { + const txnId = Date.now(); + const data = await this.fetchJson( + `${this.serverUrl}/r0/rooms/${encodeURIComponent( + roomId + )}/send/${event_type}/${encodeURIComponent(txnId)}`, + { + method: "PUT", + body: JSON.stringify(content), + headers: { Authorization: `Bearer ${this.accessToken}` }, + } + ); + return data.event_id; + } + + async uploadFile(file: File | Blob): Promise<string> { + const fileName = (file as File).name || Date.now(); const mediaUrl = this.serverUrl?.slice(0, -1 * "/client".length); const res = await fetch( `${mediaUrl}/media/r0/upload?filename=${encodeURIComponent( @@ -369,7 +397,6 @@ export default class MatrixClient { return info; } - // TODO allow filters async getTimeline(roomId: string, limit: number, filter: object = { limit: 30, types: ["m.image", "m.image_gallery"] }) {// eslint-disable-line unicorn/no-object-as-default-parameter if (!this.accessToken) { console.error("No access token"); @@ -407,7 +434,7 @@ export default class MatrixClient { * Follow a user by subscribing to their room. * @param {string} userId */ - followUser(userId: string) { - return this.joinProfileRoom(userId); + async followUser(userId: string) { + return await this.joinProfileRoom(userId); } } \ No newline at end of file diff --git a/helpers/utils.ts b/helpers/utils.ts @@ -0,0 +1,18 @@ +export interface IDeferred<T> { + resolve: (value: T) => void; + reject: (reason?: any) => void; + promise: Promise<T>; +} + +// Returns a Deferred +export function defer<T = void>(): IDeferred<T> { + let resolve!: (value: T) => void; + let reject!: (reason?: any) => void; + + const promise = new Promise<T>((_resolve, _reject) => { + resolve = _resolve; + reject = _reject; + }); + + return { resolve, reject, promise }; +} +\ No newline at end of file diff --git a/helpers/workers/blurhash.worker.ts b/helpers/workers/blurhash.worker.ts @@ -0,0 +1,35 @@ +/* +Copyright 2021 The Matrix.org Foundation C.I.C. +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { encode } from "blurhash"; + +const ctx: Worker = self as any; + +interface IBlurhashWorkerRequest { + seq: number; + imageData: ImageData; +} + +ctx.addEventListener("message", (event: MessageEvent<IBlurhashWorkerRequest>): void => { + const { seq, imageData } = event.data; + const blurhash = encode( + imageData.data, + imageData.width, + imageData.height, + // use 4 components on the longer dimension, if square then both + imageData.width >= imageData.height ? 4 : 3, + imageData.height >= imageData.width ? 4 : 3, + ); + + ctx.postMessage({ seq, blurhash }); +}); +\ No newline at end of file diff --git a/package-lock.json b/package-lock.json @@ -29,6 +29,7 @@ "@types/cors": "2.8.12", "@types/node": "17.0.12", "@types/node-localstorage": "1.3.0", + "@types/offscreencanvas": "2019.6.4", "@types/pouchdb": "6.4.0", "@types/react": "17.0.38", "@types/validator": "13.7.1", @@ -1345,6 +1346,12 @@ "integrity": "sha512-Gj7cI7z+98M282Tqmp2K5EIsoouUEzbBJhQQzDE3jSIRk6r9gsz0oUokqIUR4u1R3dMHo0pDHM7sNOHyhulypw==", "dev": true }, + "node_modules/@types/offscreencanvas": { + "version": "2019.6.4", + "resolved": "https://registry.npmjs.org/@types/offscreencanvas/-/offscreencanvas-2019.6.4.tgz", + "integrity": "sha512-u8SAgdZ8ROtkTF+mfZGOscl0or6BSj9A4g37e6nvxDc+YB/oDut0wHkK2PBBiC2bNR8TS0CPV+1gAk4fNisr1Q==", + "dev": true + }, "node_modules/@types/parse-json": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.0.tgz", @@ -8372,6 +8379,12 @@ "integrity": "sha512-Gj7cI7z+98M282Tqmp2K5EIsoouUEzbBJhQQzDE3jSIRk6r9gsz0oUokqIUR4u1R3dMHo0pDHM7sNOHyhulypw==", "dev": true }, + "@types/offscreencanvas": { + "version": "2019.6.4", + "resolved": "https://registry.npmjs.org/@types/offscreencanvas/-/offscreencanvas-2019.6.4.tgz", + "integrity": "sha512-u8SAgdZ8ROtkTF+mfZGOscl0or6BSj9A4g37e6nvxDc+YB/oDut0wHkK2PBBiC2bNR8TS0CPV+1gAk4fNisr1Q==", + "dev": true + }, "@types/parse-json": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.0.tgz", diff --git a/package.json b/package.json @@ -40,6 +40,7 @@ "@types/cors": "2.8.12", "@types/node": "17.0.12", "@types/node-localstorage": "1.3.0", + "@types/offscreencanvas": "2019.6.4", "@types/pouchdb": "6.4.0", "@types/react": "17.0.38", "@types/validator": "13.7.1", diff --git a/pages/api/directory.ts b/pages/api/directory.ts @@ -0,0 +1,109 @@ +import Cors from 'cors'; +import initMiddleware from '../../helpers/init-middleware'; +import type { NextApiRequest, NextApiResponse } from 'next'; +import path from 'node:path'; +import ServerOpenID from '../../helpers/ss-well-known'; +import { Sequelize } from 'sequelize-typescript'; +import User from '../../helpers/db/Users'; + +// Initialize the cors middleware +const cors = initMiddleware( + Cors({ + // Only allow requests with GET, POST, DELETE and OPTIONS + methods: ['GET', 'POST', 'DELETE', 'OPTIONS'], + }) +); + +const DB_PATH = path.join(process.cwd(), "matrix-art-db/db.sqlite"); +const db = new Sequelize({ + dialect: 'sqlite', + storage: DB_PATH +}); +db.addModels([User]); + +export const get_data = async () => { + if (process.env.PLAYWRIGHT === '1') { + console.log("Running in tests!"); + return [ + new User({ + "mxid": "@mtrnord:art.midnightthoughts.space", + "public_user_room": "#@mtrnord:art.midnightthoughts.space" + }) + ]; + } + await User.sync(); + return await User.findAll(); +}; + +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + + // Run cors + await cors(req, res); + + if (req.method !== "GET" && req.method !== "POST" && req.method !== "DELETE") { + res.status(405).json({}); + return; + } + + try { + await db.authenticate(); + console.log('Connection has been established successfully.'); + } catch (error) { + console.error('Unable to connect to the database:', error); + } + + if (req.method == "GET") { + try { + const db_data = await get_data(); + res.status(200).json({ data: db_data }); + } catch (error) { + res.status(502).json({}); + console.error(error); + } + } else if (req.method == "POST") { + const data: { + user_id: string; + user_room: string; + access_token: string; + } = JSON.parse(req.body); + if (await (new ServerOpenID().verify(data.user_id, data.access_token))) { + try { + await User.sync(); + await User.create({ + mxid: data.user_id, + public_user_room: data.user_room + }); + res.status(201).json({}); + } catch (error: any) { + if (error.status === 409 && error.name === "conflict") { + res.status(200).json({ "error": "User already existed", "error_code": "001" }); + } + res.status(502).json({}); + console.error(error); + } + } else { + res.status(401).json({}); + } + } else if (req.method == "DELETE") { + const data: { + user_id: string; + access_token: string; + } = JSON.parse(req.body); + if (await (new ServerOpenID().verify(data.user_id, data.access_token))) { + try { + await User.sync(); + await User.destroy({ + where: { + mxid: data.user_id + } + }); + res.status(200).json({}); + } catch (error) { + res.status(502).json({}); + console.error(error); + } + } else { + res.status(401).json({}); + } + } +} +\ No newline at end of file diff --git a/pages/api/directory.tsx b/pages/api/directory.tsx @@ -1,107 +0,0 @@ -import Cors from 'cors'; -import initMiddleware from '../../helpers/init-middleware'; -import type { NextApiRequest, NextApiResponse } from 'next'; -import path from 'node:path'; -import ServerOpenID from '../../helpers/ss-well-known'; -import { Sequelize } from 'sequelize-typescript'; -import User from '../../helpers/db/Users'; - -// Initialize the cors middleware -const cors = initMiddleware( - Cors({ - // Only allow requests with GET, POST, DELETE and OPTIONS - methods: ['GET', 'POST', 'DELETE', 'OPTIONS'], - }) -); - -const DB_PATH = path.join(process.cwd(), "matrix-art-db/db.sqlite"); -const db = new Sequelize({ - dialect: 'sqlite', - storage: DB_PATH -}); -db.addModels([User]); - -export const get_data = async () => { - if (process.env.PLAYWRIGHT === '1') { - console.log("Running in tests!"); - return [ - new User({ - "mxid": "@mtrnord:art.midnightthoughts.space", - "public_user_room": "#@mtrnord:art.midnightthoughts.space" - }) - ]; - } - await User.sync(); - return await User.findAll(); -}; - -// TODO this is fully insecured. Make sure to use OpenID or something to verify the user of this. -export default async function handler(req: NextApiRequest, res: NextApiResponse) { - - // Run cors - await cors(req, res); - - try { - await db.authenticate(); - console.log('Connection has been established successfully.'); - } catch (error) { - console.error('Unable to connect to the database:', error); - } - - if (req.method == "GET") { - try { - const db_data = await get_data(); - res.status(200).json({ data: db_data }); - } catch (error) { - res.status(502).json({}); - console.error(error); - } - } else if (req.method == "POST") { - const data: { - user_id: string; - user_room: string; - access_token: string; - } = JSON.parse(req.body); - if (await (new ServerOpenID().verify(data.user_id, data.access_token))) { - try { - await User.sync(); - await User.create({ - mxid: data.user_id, - public_user_room: data.user_room - }); - res.status(201).json({}); - } catch (error: any) { - if (error.status === 409 && error.name === "conflict") { - res.status(200).json({ "error": "User already existed", "error_code": "001" }); - } - res.status(502).json({}); - console.error(error); - } - } else { - res.status(401).json({}); - } - } else if (req.method == "DELETE") { - const data: { - user_id: string; - access_token: string; - } = JSON.parse(req.body); - if (await (new ServerOpenID().verify(data.user_id, data.access_token))) { - try { - await User.sync(); - await User.destroy({ - where: { - mxid: data.user_id - } - }); - res.status(200).json({}); - } catch (error) { - res.status(502).json({}); - console.error(error); - } - } else { - res.status(401).json({}); - } - } else { - res.status(405).json({}); - } -} -\ No newline at end of file diff --git a/pages/api/submitSearch.ts b/pages/api/submitSearch.ts @@ -0,0 +1,59 @@ +import initMiddleware from "../../helpers/init-middleware"; +import Cors from 'cors'; +import { NextApiRequest, NextApiResponse } from "next"; +import MeiliSearch from "meilisearch"; +import ServerOpenID from "../../helpers/ss-well-known"; + +// Initialize the cors middleware +const cors = initMiddleware( + Cors({ + // Only allow requests with GET, POST, DELETE and OPTIONS + methods: ['POST', 'OPTIONS'], + }) +); + +// TODO solve index for gallery +export type SearchMedia = { + event_id: string; + title: string; + description: string; + tags: string; + license: string; + sender: string; + nsfw: string; + mxc_url: string; +}; + +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + const client = new MeiliSearch({ + host: process.env.SEARCH_URL || 'http://127.0.0.1:7700', apiKey: process.env.MEILI_MASTER_KEY || 'xxx' + }); + + // Run cors + await cors(req, res); + + if (req.method !== "POST") { + res.status(405).json({}); + return; + } + + + const data: { + docs: SearchMedia[]; + access_token?: string; + user_id?: string; + } = JSON.parse(req.body); + + if (await (new ServerOpenID().verify(data.user_id!, data.access_token!))) { + data.docs.map(doc => { + doc.event_id = doc.event_id.replace("$", ""); + return doc; + }); + await client.index('posts').addDocuments(data.docs); + res.status(200).json({}); + return; + } { + res.status(401).json({}); + return; + } +} +\ No newline at end of file diff --git a/pages/index.tsx b/pages/index.tsx @@ -31,21 +31,13 @@ class Home extends PureComponent<Props, State>{ render() { const { image_events } = this.state; - const metadata: { "@context": string; "@type": string; contentUrl: string; license: string; thumbnail: any; }[] = image_events.flatMap(event => { + const metadata: { "@context": string; "@type": string; contentUrl: string; license: string; thumbnail?: any; }[] = image_events.flatMap(event => { if (isImageGalleryEvent(event)) { return event.content['m.image_gallery'].map(image => { - return { + const metadata = { "@context": "https://schema.org/", "@type": "ImageObject", "contentUrl": this.context.client?.downloadLink(image['m.file'].url)!, - "thumbnail": { - "@context": "https://schema.org/", - "@type": "ImageObject", - "contentUrl": this.context.client?.downloadLink(image['m.thumbnail'][0].url)!, - "license": "https://creativecommons.org/licenses/by-nc-nd/4.0/", - "author": event.content.displayname, - "name": image['m.text'] - }, "encodingFormat": image['m.file'].mimetype, // TODO get this from the event itself "license": "https://creativecommons.org/licenses/by-nc-nd/4.0/", @@ -54,20 +46,26 @@ class Home extends PureComponent<Props, State>{ "width": image['m.image'].width, "height": image['m.image'].height }; + if (image['m.thumbnail']) { + if (image['m.thumbnail'].length > 0) { + //@ts-ignore TS is not able to figure out types here + metadata["thumbnail"] = { + "@context": "https://schema.org/", + "@type": "ImageObject", + "contentUrl": this.context.client?.downloadLink(image['m.thumbnail'][0].url)!, + "license": "https://creativecommons.org/licenses/by-nc-nd/4.0/", + "author": event.content.displayname, + "name": image['m.text'] + }; + } + } + return metadata; }); } else { - return { + const metadata = { "@context": "https://schema.org/", "@type": "ImageObject", "contentUrl": this.context.client?.downloadLink(event.content['m.file'].url)!, - "thumbnail": { - "@context": "https://schema.org/", - "@type": "ImageObject", - "contentUrl": this.context.client?.downloadLink(event.content['m.thumbnail'][0].url)!, - "license": "https://creativecommons.org/licenses/by-nc-nd/4.0/", - "author": event.content.displayname, - "name": event.content['m.text'] - }, "encodingFormat": event.content['m.file'].mimetype, // TODO get this from the event itself "license": "https://creativecommons.org/licenses/by-nc-nd/4.0/", @@ -76,6 +74,22 @@ class Home extends PureComponent<Props, State>{ "width": event.content['m.image'].width, "height": event.content['m.image'].height }; + + if (event.content['m.thumbnail']) { + if (event.content['m.thumbnail'].length > 0) { + //@ts-ignore TS is not able to figure out types here + metadata["thumbnail"] = { + "@context": "https://schema.org/", + "@type": "ImageObject", + "contentUrl": this.context.client?.downloadLink(event.content['m.thumbnail'][0].url)!, + "license": "https://creativecommons.org/licenses/by-nc-nd/4.0/", + "author": event.content.displayname, + "name": event.content['m.text'] + }; + } + } + + return metadata; } }); return ( @@ -137,7 +151,7 @@ export const getServerSideProps: GetServerSideProps = async (context) => { const roomId = await client?.followUser(user.public_user_room); const events = await client?.getTimeline(roomId, 100); // Filter events by type - let images = events.filter((event) => event.type == "m.image_gallery" || event.type == "m.image") as MatrixImageEvents[]; + let images = events.filter((event) => (event.type == "m.image_gallery" || event.type == "m.image") && !event.unsigned?.redacted_because) as MatrixImageEvents[]; images = await Promise.all(images.map(async (image) => { try { const profile = await client.getProfile(image.sender); diff --git a/pages/post/[id].tsx b/pages/post/[id].tsx @@ -191,7 +191,7 @@ class Post extends PureComponent<Props, State> { <div className="flex flex-col items-start lg:min-w-[60rem] lg:w-[60rem]"> <h1 className="my-4 text-6xl text-gray-900 dark:text-gray-200 font-bold">{post_title}</h1> <h3 className="cursor-pointer mt-0 mb-4 text-l text-gray-900 dark:text-gray-200 font-normal inline-flex"> - <span className="block object-cover rounded-full mr-4">{avatar_url ? <img className="object-cover rounded-full" src={this.context.client.downloadLink(avatar_url)!} height="24" width="24" alt={displayname} title={displayname} /> : undefined}</span> + {avatar_url ? <span className="block object-cover rounded-full mr-4"> <img className="object-cover rounded-full" src={this.context.client.downloadLink(avatar_url)!} height="24" width="24" alt={displayname} title={displayname} /> </span> : undefined} <Link href={"/profile/" + encodeURIComponent(image_event.sender)} passHref><span className='hover:text-teal-400'>{displayname}</span></Link> </h3> {isImageGalleryEvent(image_event) ? this.renderImageGalleryTags(image_event) : (isImageEvent(image_event) ? this.renderSingleImageTags(image_event) : <div key={(image_event as MatrixEventBase).event_id + "tags"}></div>)} @@ -255,18 +255,11 @@ class Post extends PureComponent<Props, State> { if (!url || !thumbnail_url) { return <></>; } + const metadata = { "@context": "https://schema.org/", "@type": "ImageObject", contentUrl: url, - "thumbnail": { - "@context": "https://schema.org/", - "@type": "ImageObject", - "contentUrl": this.context.client?.downloadLink(imageEvent.content['m.thumbnail'][0].url)!, - "license": "https://creativecommons.org/licenses/by-nc-nd/4.0/", - "author": imageEvent.content.displayname, - "name": imageEvent.content['m.text'] - }, encodingFormat: imageEvent.content['m.file'].mimetype, // TODO get this from the event itself license: "https://creativecommons.org/licenses/by-nc-nd/4.0/", @@ -275,6 +268,19 @@ class Post extends PureComponent<Props, State> { "width": imageEvent.content['m.image'].width, "height": imageEvent.content['m.image'].height }; + if (imageEvent.content['m.thumbnail']) { + if (imageEvent.content['m.thumbnail'].length > 0) { + //@ts-ignore TS is not able to figure out types here + metadata["thumbnail"] = { + "@context": "https://schema.org/", + "@type": "ImageObject", + "contentUrl": this.context.client?.downloadLink(imageEvent.content['m.thumbnail'][0].url)!, + "license": "https://creativecommons.org/licenses/by-nc-nd/4.0/", + "author": imageEvent.content.displayname, + "name": imageEvent.content['m.text'], + }; + } + } const blurhash = imageEvent.content['xyz.amorgan.blurhash']; const image_html = blurhash ? ( <div className="flex"> @@ -320,7 +326,7 @@ class Post extends PureComponent<Props, State> { const metadata: { "@context": string; "@type": string; contentUrl: string; license: string; author: string; name: string; thumbnail: any; encodingFormat: string; width: number; height: number; }[] = []; const images = imageEvent.content['m.image_gallery'].map(image => { const url = this.context.client?.downloadLink(image["m.file"].url); - const thumbnail_url = this.context.client?.downloadLink(image['m.thumbnail'][0].url); + const thumbnail_url = this.context.client?.downloadLink(image['m.thumbnail'] ? (image['m.thumbnail'].length > 0 ? image['m.thumbnail'][0].url : image["m.file"].url) : image["m.file"].url); if (!url || !thumbnail_url) { return <></>; } @@ -328,14 +334,14 @@ class Post extends PureComponent<Props, State> { "@context": "https://schema.org/", "@type": "ImageObject", contentUrl: url, - "thumbnail": { + "thumbnail": image['m.thumbnail'] ? (image['m.thumbnail'].length > 0 ? { "@context": "https://schema.org/", "@type": "ImageObject", "contentUrl": this.context.client?.downloadLink(image['m.thumbnail'][0].url)!, "license": "https://creativecommons.org/licenses/by-nc-nd/4.0/", "author": imageEvent.content.displayname, "name": image['m.text'] - }, + } : undefined) : undefined, encodingFormat: image['m.file'].mimetype, // TODO get this from the event itself license: "https://creativecommons.org/licenses/by-nc-nd/4.0/", @@ -426,7 +432,7 @@ export const getServerSideProps: GetServerSideProps = async (context) => { const roomId = await client?.followUser(user.public_user_room); const events = await client?.getTimeline(roomId, 100); // Filter events by type - const image_event = events.find((event) => (event.type === "m.image_gallery" || event.type === "m.image") && event.event_id === event_id); + const image_event = events.find((event) => ((event.type == "m.image_gallery" || event.type == "m.image") && !event.unsigned?.redacted_because) && event.event_id === event_id); if (image_event == undefined) { continue; } @@ -438,7 +444,7 @@ export const getServerSideProps: GetServerSideProps = async (context) => { event_id: event_id, hasFullyLoaded: true, displayname: profile.displayname, - avatar_url: profile.avatar_url + avatar_url: profile.avatar_url || null } }; } catch (error) { diff --git a/pages/profile/[userid].tsx b/pages/profile/[userid].tsx @@ -140,7 +140,7 @@ class Profile extends PureComponent<Props, State> { } const banner_event = events.find(event => event.type === "matrixart.profile_banner"); - const image_events = events.filter((event) => event.type == "m.image_gallery" || event.type == "m.image") as MatrixImageEvents[]; + const image_events = events.filter((event) => (event.type == "m.image_gallery" || event.type == "m.image") && !event.unsigned?.redacted_because) as MatrixImageEvents[]; // TODO opengraph shows mxid instead of displayname return ( <div className="min-h-full flex flex-col justify-between bg-[#f8f8f8] dark:bg-[#06070D]">