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:
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]">