matrix-art

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

commit 265b9437b5c71835eb8e0ee4c991d23cc18f4052
parent 04fa313faa4bc5e98603ce4eb2fe833d28d8ab37
Author: MTRNord <mtrnord1@gmail.com>
Date:   Fri, 11 Feb 2022 16:11:49 +0100

Make storage async, use pure files on server side and indexeddb (plus fallbacks) on client side

Diffstat:
M.eslintrc.json | 4+++-
Mcomponents/ClientContext.tsx | 22+++++++++++++---------
Mcomponents/Header.tsx | 15++++++---------
Mcomponents/submit_page/main.tsx | 26+++++++++++++++++---------
Mhelpers/matrix_client.ts | 56++++++++++++++++++++++++++++++--------------------------
Mhelpers/storage.ts | 96++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------------------
Mnext.config.js | 9+++++++++
Mpackage-lock.json | 63+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackage.json | 5++++-
Mpages/_app.tsx | 111+++++++++++++++++++++++++++++++++++++++++++++++++------------------------------
Mpages/api/posts/feed.rss.ts | 5+++--
Mpages/api/submitSearch.ts | 2+-
Mpages/index.tsx | 8+++++---
Mpages/login.tsx | 14++++++++++----
Mpages/logout.tsx | 4++--
Mpages/post/[id].tsx | 32+++++++++++++++++++++++---------
Mpages/profile/[userid].tsx | 44+++++++++++++++++++++++++++++---------------
Mpages/submit.tsx | 2+-
18 files changed, 354 insertions(+), 164 deletions(-)

diff --git a/.eslintrc.json b/.eslintrc.json @@ -13,6 +13,8 @@ "unicorn/prevent-abbreviations": "off", "unicorn/filename-case": "off", "unicorn/prefer-ternary": "off", - "unicorn/no-lonely-if": "off" + "unicorn/no-lonely-if": "off", + "unicorn/empty-brace-spaces": "off", + "unicorn/prefer-node-protocol": "off" } } \ No newline at end of file diff --git a/components/ClientContext.tsx b/components/ClientContext.tsx @@ -1,12 +1,16 @@ import React, { Context } from "react"; -import Client from "../helpers/matrix_client"; -import Storage from "../helpers/storage"; +import MatrixClient from "../helpers/matrix_client"; -// Actual user client -const client = new Client(new Storage("main")); +type ContextData = { + client?: MatrixClient; + guest_client?: MatrixClient; + is_generating_guest: boolean; +}; -// Client used if logged in for the Homepage to prevent excessive room joining -const guest_client = new Client(new Storage("guest")); -const ClientContext: Context<{ client: Client; guest_client: Client; is_generating_guest: boolean; }> = React.createContext<{ client: Client; guest_client: Client; is_generating_guest: boolean; }>({ client: client, guest_client: guest_client, is_generating_guest: false }); +const ClientContext: Context<ContextData> = React.createContext<ContextData>({ + client: undefined, + guest_client: undefined, + is_generating_guest: false +}); -export { ClientContext, client, guest_client }; -\ No newline at end of file +export { ClientContext }; +\ No newline at end of file diff --git a/components/Header.tsx b/components/Header.tsx @@ -81,8 +81,7 @@ export default class Header extends PureComponent<Props, State> { <nav className='flex lg:flex-shrink-0 my-4'> <ClientContext.Consumer> {value => { - const isGuest = value.client.isGuest === null || value.client.isGuest === undefined ? true : value.client.isGuest; - console.log("isGuest1:", isGuest); + const isGuest = value.client?.isGuest === null || value.client?.isGuest === undefined ? true : value.client.isGuest; if (typeof window !== "undefined" && isGuest) { return ( <> @@ -91,10 +90,10 @@ export default class Header extends PureComponent<Props, State> { </> ); } else if (typeof window !== "undefined") { - if (this.state.directory_data.some(thing => thing.mxid == this.context.client.userId)) { + if (this.state.directory_data.some(thing => thing.mxid == this.context.client?.userId)) { return ( <> - <span className='px-4 h-auto min-w-[1.5rem] flex items-center whitespace-nowrap cursor-pointer text-gray-900 dark:text-gray-200 font-medium brightness-100 hover:brightness-75 duration-200 ease-in-out transition-all'><Link href={"/profile/" + encodeURIComponent(this.context.client.userId!)}>{i18n?.t('Profile') ?? 'Profile'}</Link></span> + <span className='px-4 h-auto min-w-[1.5rem] flex items-center whitespace-nowrap cursor-pointer text-gray-900 dark:text-gray-200 font-medium brightness-100 hover:brightness-75 duration-200 ease-in-out transition-all'><Link href={"/profile/" + encodeURIComponent(this.context.client?.userId ?? "")}>{i18n?.t('Profile') ?? 'Profile'}</Link></span> <span className='px-4 h-auto min-w-[1.5rem] flex items-center whitespace-nowrap cursor-pointer text-gray-900 dark:text-gray-200 font-medium brightness-100 hover:brightness-75 duration-200 ease-in-out transition-all'><Link href="/logout/">{i18n?.t('Logout') ?? 'Logout'}</Link></span> </> ); @@ -109,8 +108,7 @@ export default class Header extends PureComponent<Props, State> { <ClientContext.Consumer> {value => { - const isGuest = value.client.isGuest === null || value.client.isGuest === undefined ? true : value.client.isGuest; - console.log("isGuest2:", isGuest); + const isGuest = value.client?.isGuest === null || value.client?.isGuest === undefined ? true : value.client.isGuest; if (typeof window !== "undefined" && !isGuest) { return (<span className='lg:opacity-100 opacity-0 inline-block bg-gray-900 dark:bg-gray-200 w-[1px] lg:h-7 h-0'></span>); } else { @@ -123,10 +121,9 @@ export default class Header extends PureComponent<Props, State> { <div className='flex'> <ClientContext.Consumer> {value => { - const isGuest = value.client.isGuest === null || value.client.isGuest === undefined ? true : value.client.isGuest; - console.log("isGuest3:", isGuest); + const isGuest = value.client?.isGuest === null || value.client?.isGuest === undefined ? true : value.client.isGuest; if (typeof window !== "undefined" && !isGuest) { - if (this.state.directory_data.some(thing => thing.mxid == this.context.client.userId)) { + if (this.state.directory_data.some(thing => thing.mxid == this.context.client?.userId)) { return <Link href="/submit"><a className='inline-flex justify-center items-center text-teal-400 hover:text-teal-200 bg-transparent relative h-14 min-w-[9.25rem] z-[2] cursor-pointer font-bold'>{i18n?.t('Submit') ?? 'Submit'}</a></Link>; } else { return <a className='inline-flex justify-center items-center text-teal-400 hover:text-teal-200 bg-transparent relative h-14 min-w-[9.25rem] z-[2] cursor-pointer font-bold'>{i18n?.t('Setup Account') ?? 'Setup Account'}</a>; diff --git a/components/submit_page/main.tsx b/components/submit_page/main.tsx @@ -150,7 +150,7 @@ class MainSubmissionForm extends PureComponent<Props, State> { toast.dismiss(); - if (this.context.client.isGuest) { + if (this.context.client?.isGuest) { toast.error(() => <div><h2 className="text-xl text-white">{i18n?.t("Error")}</h2><br />{i18n?.t("You are not logged in!")}</div>, { autoClose: false }); @@ -184,7 +184,7 @@ class MainSubmissionForm extends PureComponent<Props, State> { const nsfw = `${index}_nsfw`; const file = this.props.files[index]; - if (!this.context.client.profileRoomId) { + if (!this.context.client?.profileRoomId) { toast.error(() => <div><h2 className="text-xl text-white">{i18n?.t("Error")}</h2><br />{i18n?.t("Unable to find your profile Room. Please log in again!")}</div>, { autoClose: false }); @@ -229,8 +229,14 @@ class MainSubmissionForm extends PureComponent<Props, State> { 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 }) }); + 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.setState({ submit_in_process: false }); } @@ -374,7 +380,7 @@ class MainSubmissionForm extends PureComponent<Props, State> { private async generateThumbnailsAndUpload(image_infos: { width: number; height: number; img: HTMLImageElement; }[]): Promise<{ index: number; meta: ThumbnailData; }[]> { const thumbnails = []; - if (!this.context.client.isGuest) { + 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]; @@ -399,8 +405,10 @@ class MainSubmissionForm extends PureComponent<Props, State> { 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; + const result = await this.context.client?.uploadFile(thumbnail_data.thumbnail); + if (result) { + thumbnail_data.thumbnail_meta["m.thumbnail"]![0].url = result; + } thumbnails.push({ index: index, meta: thumbnail_data.thumbnail_meta }); } } @@ -409,11 +417,11 @@ class MainSubmissionForm extends PureComponent<Props, State> { private async doUpload() { const urls = []; - if (!this.context.client.isGuest) { + 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 result = await this.context.client.uploadFile(file); + const result = await this.context.client?.uploadFile(file); urls.push({ index: index, url: result }); } } diff --git a/helpers/matrix_client.ts b/helpers/matrix_client.ts @@ -7,12 +7,6 @@ export const constMatrixArtServer = process.env.NEXT_PUBLIC_DEFAULT_SERVER_URL ? export default class MatrixClient { private joinedRooms: Map<string, string> = new Map<string, string>(); // room alias -> room ID private userProfileCache: Map<string, { displayname?: string; avatar_url?: string; }> = new Map(); // user_id -> {display_name; avatar;} - 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; } @@ -27,32 +21,42 @@ export default class MatrixClient { return this.joinedRooms.get(`#${this.userId}`) ?? this._profileRoomId; } - constructor(private storage: Storage) { - this.serverUrl = this.storage.getItem("serverUrl"); - this._userId = this.storage.getItem("userId"); - this._accessToken = this.storage.getItem("accessToken"); - this._isGuest = this.storage.getItem("isGuest") === undefined ? undefined : (this.storage.getItem("isGuest") === "true"); - this.serverName = this.storage.getItem("serverName"); - this._profileRoomId = this.storage.getItem("profileRoomId"); + private constructor( + private storage: Storage, + private serverUrl?: string, + private _userId?: string, + private _accessToken?: string, + private _isGuest?: boolean, + private serverName?: string, + private _profileRoomId?: string + ) { } - private saveAuthState() { - if (!this.storage) { - return; - } - this.storage.setOrDelete("serverUrl", this.serverUrl); - this.storage.setOrDelete("userId", this._userId); - this.storage.setOrDelete("accessToken", this._accessToken); - this.storage.setOrDelete("serverName", this.serverName); - this.storage.setOrDelete("isGuest", this._isGuest?.toString()); + public static async init(storage: Storage): Promise<MatrixClient> { + const serverUrl = await storage.getItem("serverUrl"); + const _userId = await storage.getItem("userId"); + const _accessToken = await storage.getItem("accessToken"); + const _isGuest = await storage.getItem("isGuest") === undefined ? undefined : (await storage.getItem("isGuest") === "true"); + const serverName = await storage.getItem("serverName"); + const _profileRoomId = await storage.getItem("profileRoomId"); + return new MatrixClient(storage, serverUrl, _userId, _accessToken, _isGuest, serverName, _profileRoomId); + } + + private async saveAuthState() { + await this.storage.setOrDelete("serverUrl", this.serverUrl); + await this.storage.setOrDelete("userId", this._userId); + await this.storage.setOrDelete("accessToken", this._accessToken); + await this.storage.setOrDelete("serverName", this.serverName); + await this.storage.setOrDelete("isGuest", this._isGuest?.toString()); } - private generateToken(len: number) { + private async generateToken(len: number) { var arr = new Uint8Array(len / 2); if (typeof window !== "undefined") { window.crypto.getRandomValues(arr); } else { - require("crypto").randomFillSync(arr); // eslint-disable-line unicorn/prefer-module + const crypto = await import("crypto"); + crypto.randomFillSync(arr); } /* eslint-disable unicorn/prefer-spread */ return Array.from(arr, (num) => { @@ -311,7 +315,7 @@ export default class MatrixClient { } ); if (isMyself) { - this.storage?.setOrDelete("profileRoomId", data.room_id); + await this.storage?.setOrDelete("profileRoomId", data.room_id); } this.joinedRooms.set(roomAlias, data.room_id); return data.room_id; @@ -355,7 +359,7 @@ export default class MatrixClient { ); this.joinedRooms.set(roomAlias, data.room_id); - this.storage?.setOrDelete("profileRoomId", data.room_id); + await this.storage?.setOrDelete("profileRoomId", data.room_id); return data.room_id; } else { throw error; diff --git a/helpers/storage.ts b/helpers/storage.ts @@ -1,92 +1,128 @@ -const STORAGE_VERSION = 4; +const STORAGE_VERSION = 5; export default class Storage { - private prefix; - private nodeLocalStorage?: any; - constructor(prefix: string) { - this.prefix = prefix; + private constructor(private prefix: string, private localFolder?: string) { } + + public static async init(prefix: string): Promise<Storage> { if (typeof window === "undefined") { - var LocalStorage = require('node-localstorage').LocalStorage; // eslint-disable-line unicorn/prefer-module - var path = require('node:path'); // eslint-disable-line unicorn/prefer-module - var fs = require('node:fs'); // eslint-disable-line unicorn/prefer-module + const path = await import('path'); + const fs = await import('fs'); const dir = path.join(process.cwd(), "localstorage"); - if (!fs.existsSync(dir)) { + const dir_with_prefix = path.join(dir, prefix); + try { + await fs.promises.stat(dir); + await fs.promises.stat(dir_with_prefix); + } catch { try { - fs.mkdirSync(dir); + await fs.promises.mkdir(dir); + await fs.promises.mkdir(dir_with_prefix); } catch { console.log("race while folder was checked"); } } - this.nodeLocalStorage = new LocalStorage(dir); + return new Storage(prefix, dir); + } else { + return new Storage(prefix); } } - private ensureVersion() { + + private async ensureVersion() { let version; if (typeof window !== "undefined") { - version = window.localStorage.getItem("version"); + const localforage = await import('localforage'); + version = await localforage.getItem("version"); } else { try { - version = this.nodeLocalStorage?.getItem("version"); + const fs = await import('fs'); + const path = await import('path'); + version = await fs.promises.readFile(path.join(this.localFolder!, "version"), { encoding: "utf8" }); } catch { //No-op } } if (version === undefined || version === null || version !== STORAGE_VERSION.toString()) { if (typeof window !== "undefined") { - window.localStorage.clear(); - window.localStorage.setItem("version", STORAGE_VERSION.toString()); + const localforage = await import('localforage'); + await localforage.clear(); + await localforage.setItem("version", STORAGE_VERSION.toString()); } else { - this.nodeLocalStorage?.clear(); - this.nodeLocalStorage?.setItem("version", STORAGE_VERSION.toString()); + const fs = await import('fs'); + const path = await import('path'); + try { + const stat = await fs.promises.stat(path.join(this.localFolder!, "version")); + if (stat.isFile()) { + await fs.promises.unlink(path.join(this.localFolder!, "version")); + } + } catch { } + await fs.promises.writeFile(path.join(this.localFolder!, "version"), STORAGE_VERSION.toString(), { encoding: "utf8", flag: "w+" }); } } } - getItem(key: string): string | undefined { + async getItem(key: string): Promise<string | undefined> { this.ensureVersion(); if (typeof window !== "undefined") { - const item = window.localStorage.getItem(this.prefix + key); + const localforage = await import('localforage'); + const item = await localforage.getItem(this.prefix + key) as string | null; if (item === null) { return undefined; } return item; } else { try { - return this.nodeLocalStorage?.getItem(this.prefix + key); + const fs = await import('fs'); + const path = await import('path'); + try { + const stat = await fs.promises.stat(path.join(this.localFolder!, this.prefix, key)); + if (stat.isFile()) { + return fs.promises.readFile(path.join(this.localFolder!, this.prefix, key), { encoding: "utf8" }); + } + } catch { } } catch { //No-op } } } - setItem(key: string, value: string): void { + async setItem(key: string, value: string): Promise<void> { this.ensureVersion(); if (typeof window !== "undefined") { - return window.localStorage.setItem(this.prefix + key, value); + const localforage = await import('localforage'); + await localforage.setItem(this.prefix + key, value); } else { try { - return this.nodeLocalStorage?.setItem(this.prefix + key, value); + const fs = await import('fs'); + const path = await import('path'); + return await fs.promises.writeFile(path.join(this.localFolder!, this.prefix, key), value, { encoding: "utf8", flag: "w+" }); } catch { //No-op } } } - removeItem(key: string): void { + async removeItem(key: string): Promise<void> { this.ensureVersion(); if (typeof window !== "undefined") { - return window.localStorage.removeItem(this.prefix + key); + const localforage = await import('localforage'); + await localforage.removeItem(this.prefix + key); } else { try { - return this.nodeLocalStorage?.removeItem(this.prefix + key); + const fs = await import('fs'); + const path = await import('path'); + try { + const stat = await fs.promises.stat(path.join(this.localFolder!, this.prefix, key)); + if (stat.isFile()) { + await fs.promises.unlink(path.join(this.localFolder!, this.prefix, key)); + } + } catch { } } catch { //No-op } } } - setOrDelete(key: string, value: string | undefined): void { + async setOrDelete(key: string, value: string | undefined): Promise<void> { if (value) { - this.setItem(key, value); + await this.setItem(key, value); } else { - this.removeItem(key); + await this.removeItem(key); } } } \ No newline at end of file diff --git a/next.config.js b/next.config.js @@ -27,6 +27,15 @@ const securityHeaders = [ module.exports = { i18n, reactStrictMode: true, + webpack: (config) => { + config.resolve.fallback = { + ...config.resolve.fallback, + fs: false, + path: false + } + + return config; + }, async headers() { return [ { diff --git a/package-lock.json b/package-lock.json @@ -10,6 +10,7 @@ "cors": "2.8.5", "feed": "yshrsmz/feed#c50d7a82c5b6141039830d615b19b760e173e231", "lightgallery": "2.4.0", + "localforage": "1.10.0", "meilisearch": "0.24.0", "next": "12.0.10", "next-i18next": "10.2.0", @@ -30,6 +31,7 @@ "@babel/plugin-transform-typescript": "7.16.8", "@playwright/test": "1.18.1", "@types/cors": "2.8.12", + "@types/localforage": "^0.0.34", "@types/node": "17.0.17", "@types/node-localstorage": "1.3.0", "@types/offscreencanvas": "2019.6.4", @@ -1398,6 +1400,16 @@ "integrity": "sha1-7ihweulOEdK4J7y+UnC86n8+ce4=", "dev": true }, + "node_modules/@types/localforage": { + "version": "0.0.34", + "resolved": "https://registry.npmjs.org/@types/localforage/-/localforage-0.0.34.tgz", + "integrity": "sha1-XjHDLdh5HsS5/z70fJy1Wy0NlDg=", + "deprecated": "This is a stub types definition for localforage (https://github.com/localForage/localForage). localforage provides its own type definitions, so you don't need @types/localforage installed!", + "dev": true, + "dependencies": { + "localforage": "*" + } + }, "node_modules/@types/minimatch": { "version": "3.0.5", "resolved": "https://registry.npmjs.org/@types/minimatch/-/minimatch-3.0.5.tgz", @@ -4657,6 +4669,11 @@ "minimatch": "^3.0.4" } }, + "node_modules/immediate": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", + "integrity": "sha1-nbHb0Pr43m++D13V5Wu2BigN5ps=" + }, "node_modules/import-fresh": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz", @@ -5584,6 +5601,14 @@ "node": ">= 0.8.0" } }, + "node_modules/lie": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/lie/-/lie-3.1.1.tgz", + "integrity": "sha1-mkNrLMd0bKWd56QfpGmz77dr2H4=", + "dependencies": { + "immediate": "~3.0.5" + } + }, "node_modules/lightgallery": { "version": "2.4.0", "resolved": "https://registry.npmjs.org/lightgallery/-/lightgallery-2.4.0.tgz", @@ -5607,6 +5632,14 @@ "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", "dev": true }, + "node_modules/localforage": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/localforage/-/localforage-1.10.0.tgz", + "integrity": "sha512-14/H1aX7hzBBmmh7sGPd+AOMkkIrHM3Z1PAyGgZigA1H1p5O5ANnMyWzvpAETtG68/dC4pC0ncy3+PPGzXZHPg==", + "dependencies": { + "lie": "3.1.1" + } + }, "node_modules/locate-path": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-2.0.0.tgz", @@ -10017,6 +10050,15 @@ "integrity": "sha1-7ihweulOEdK4J7y+UnC86n8+ce4=", "dev": true }, + "@types/localforage": { + "version": "0.0.34", + "resolved": "https://registry.npmjs.org/@types/localforage/-/localforage-0.0.34.tgz", + "integrity": "sha1-XjHDLdh5HsS5/z70fJy1Wy0NlDg=", + "dev": true, + "requires": { + "localforage": "*" + } + }, "@types/minimatch": { "version": "3.0.5", "resolved": "https://registry.npmjs.org/@types/minimatch/-/minimatch-3.0.5.tgz", @@ -12586,6 +12628,11 @@ "minimatch": "^3.0.4" } }, + "immediate": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", + "integrity": "sha1-nbHb0Pr43m++D13V5Wu2BigN5ps=" + }, "import-fresh": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz", @@ -13282,6 +13329,14 @@ "type-check": "~0.4.0" } }, + "lie": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/lie/-/lie-3.1.1.tgz", + "integrity": "sha1-mkNrLMd0bKWd56QfpGmz77dr2H4=", + "requires": { + "immediate": "~3.0.5" + } + }, "lightgallery": { "version": "2.4.0", "resolved": "https://registry.npmjs.org/lightgallery/-/lightgallery-2.4.0.tgz", @@ -13299,6 +13354,14 @@ "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", "dev": true }, + "localforage": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/localforage/-/localforage-1.10.0.tgz", + "integrity": "sha512-14/H1aX7hzBBmmh7sGPd+AOMkkIrHM3Z1PAyGgZigA1H1p5O5ANnMyWzvpAETtG68/dC4pC0ncy3+PPGzXZHPg==", + "requires": { + "lie": "3.1.1" + } + }, "locate-path": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-2.0.0.tgz", diff --git a/package.json b/package.json @@ -21,6 +21,7 @@ "cors": "2.8.5", "feed": "yshrsmz/feed#c50d7a82c5b6141039830d615b19b760e173e231", "lightgallery": "2.4.0", + "localforage": "1.10.0", "meilisearch": "0.24.0", "next": "12.0.10", "next-i18next": "10.2.0", @@ -41,6 +42,7 @@ "@babel/plugin-transform-typescript": "7.16.8", "@playwright/test": "1.18.1", "@types/cors": "2.8.12", + "@types/localforage": "0.0.34", "@types/node": "17.0.17", "@types/node-localstorage": "1.3.0", "@types/offscreencanvas": "2019.6.4", @@ -60,4 +62,4 @@ "optionalDependencies": { "fsevents": "2.3.2" } -} +} +\ No newline at end of file diff --git a/pages/_app.tsx b/pages/_app.tsx @@ -1,59 +1,86 @@ import '../styles/globals.css'; import 'react-toastify/dist/ReactToastify.min.css'; import type { AppProps } from 'next/app'; -import { client, ClientContext, guest_client } from '../components/ClientContext'; -import React from 'react'; +import { ClientContext, } from '../components/ClientContext'; +import React, { PureComponent } from 'react'; import Head from 'next/head'; import { ToastContainer } from 'react-toastify'; import { appWithTranslation } from 'next-i18next'; import Footer from '../components/Footer'; import dynamic from 'next/dynamic'; +import MatrixClient from "../helpers/matrix_client"; +import Storage from "../helpers/storage"; const HeaderNoSSR = dynamic( () => import('../components/Header'), { ssr: false } ); -function MyApp({ Component, pageProps }: AppProps) { - return ( - <> - <Head> - <title key="title">Matrix Art</title> - <meta property="og:title" content="Matrix Art" key="og-title" /> - <meta property="og:type" content="website" key="og-type" /> - <meta key="viewport" name="viewport" content="initial-scale=1.0, width=device-width" /> - </Head> - <React.StrictMode> - <ClientContext.Provider - value={{ - client: client, - guest_client: guest_client, - is_generating_guest: false, - }} - > - <div className='min-h-full flex flex-col justify-between bg-[#f8f8f8] dark:bg-[#06070D]'> - <HeaderNoSSR></HeaderNoSSR> - <main className='mb-auto lg:pt-20 pt-52 z-0'> - <Component {...pageProps} /> - </main> - <Footer></Footer> - </div> - </ClientContext.Provider> - </React.StrictMode> - <ToastContainer - position="top-center" - autoClose={5000} - hideProgressBar={false} - newestOnTop={false} - closeOnClick - rtl={false} - pauseOnFocusLoss - draggable - pauseOnHover - theme="colored" - /> - </> - ); +type State = { + client?: MatrixClient; + guest_client?: MatrixClient; +}; + +class MyApp extends PureComponent<AppProps, State> { + constructor(props: AppProps) { + super(props); + this.state = {}; + } + + async componentDidMount() { + // Actual user client + const client = await MatrixClient.init(await Storage.init("main")); + + // Client used if logged in for the Homepage to prevent excessive room joining + const guest_client = await MatrixClient.init(await Storage.init("guest")); + this.setState({ + client: client, + guest_client: guest_client + }); + } + + render() { + const Component = this.props.Component; + return ( + <> + <Head> + <title key="title">Matrix Art</title> + <meta property="og:title" content="Matrix Art" key="og-title" /> + <meta property="og:type" content="website" key="og-type" /> + <meta key="viewport" name="viewport" content="initial-scale=1.0, width=device-width" /> + </Head> + <React.StrictMode> + <ClientContext.Provider + value={{ + client: this.state.client, + guest_client: this.state.guest_client, + is_generating_guest: false, + }} + > + <div className='min-h-full flex flex-col justify-between bg-[#f8f8f8] dark:bg-[#06070D]'> + <HeaderNoSSR></HeaderNoSSR> + <main className='mb-auto lg:pt-20 pt-52 z-0'> + <Component {...this.props.pageProps} /> + </main> + <Footer></Footer> + </div> + </ClientContext.Provider> + </React.StrictMode> + <ToastContainer + position="top-center" + autoClose={5000} + hideProgressBar={false} + newestOnTop={false} + closeOnClick + rtl={false} + pauseOnFocusLoss + draggable + pauseOnHover + theme="colored" + /> + </> + ); + } } export default appWithTranslation(MyApp); diff --git a/pages/api/posts/feed.rss.ts b/pages/api/posts/feed.rss.ts @@ -3,10 +3,10 @@ import Cors from 'cors'; import { NextApiRequest, NextApiResponse } from "next"; import { Feed } from "feed"; import { get_data } from "../directory"; -import { client } from "../../../components/ClientContext"; -import { constMatrixArtServer } from "../../../helpers/matrix_client"; +import MatrixClient, { constMatrixArtServer } from "../../../helpers/matrix_client"; import { MatrixImageEvents } from "../../../helpers/event_types"; import { isImageGalleryEvent } from "../../../components/FrontPageImage"; +import Storage from "../../../helpers/storage"; // Initialize the cors middleware const cors = initMiddleware( @@ -41,6 +41,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) try { const directory = await get_data(); + const client = await MatrixClient.init(await Storage.init("main")); if (!client?.accessToken) { try { diff --git a/pages/api/submitSearch.ts b/pages/api/submitSearch.ts @@ -21,7 +21,7 @@ export type SearchMedia = { license: string; sender: string; nsfw: string; - mxc_url: string; + mxc_url?: string; }; export default async function handler(req: NextApiRequest, res: NextApiResponse) { diff --git a/pages/index.tsx b/pages/index.tsx @@ -1,13 +1,14 @@ import React, { PureComponent } from 'react'; import { MatrixEventBase, MatrixImageEvents } from '../helpers/event_types'; import Head from 'next/head'; -import { client, ClientContext } from '../components/ClientContext'; +import { ClientContext } from '../components/ClientContext'; import FrontPageImage, { isImageGalleryEvent } from '../components/FrontPageImage'; import { GetServerSideProps, InferGetServerSidePropsType, } from 'next'; import { get_data } from './api/directory'; -import { constMatrixArtServer } from '../helpers/matrix_client'; +import MatrixClient, { constMatrixArtServer } from '../helpers/matrix_client'; import { serverSideTranslations } from 'next-i18next/serverSideTranslations'; import { i18n, WithTranslation } from 'next-i18next'; +import Storage from "../helpers/storage"; type Props = InferGetServerSidePropsType<typeof getServerSideProps> & WithTranslation & { }; @@ -124,7 +125,8 @@ export const getServerSideProps: GetServerSideProps = async ({ res, locale }) => 'Cache-Control', 'public, s-maxage=10, stale-while-revalidate=59' ); - console.log(`locale: ${locale ?? 'en'}`); + + const client = await MatrixClient.init(await Storage.init("main")); try { const data = await get_data(); if (!client?.accessToken) { diff --git a/pages/login.tsx b/pages/login.tsx @@ -76,18 +76,24 @@ class Login extends PureComponent<Props, State> { this.setState({ loading: true }); - await this.context.client.login(serverUrl, this.state.mxid, this.state.password, true); + await this.context.client?.login(serverUrl, this.state.mxid, this.state.password, true); if (this.state.generateProfile) { try { - await this.context.client.followUser(`#${this.context.client.userId}`); + await this.context.client?.followUser(`#${this.context.client?.userId}`); } catch (error: any) { this.setState( { error: error.message } ); } try { - const token = await this.context.client.getOpenidToken(); - const resp = await fetch("/api/directory", { method: "POST", body: JSON.stringify({ access_token: token, user_id: this.context.client.userId, user_room: `#${this.context.client.userId}` }) }); + const token = await this.context.client?.getOpenidToken(); + const resp = await fetch("/api/directory", { + method: "POST", body: JSON.stringify({ + access_token: token, + user_id: this.context.client?.userId, + user_room: `#${this.context.client?.userId}` + }) + }); const body = await resp.json(); if (body.error_code) { if (body.error_code !== "001") { diff --git a/pages/logout.tsx b/pages/logout.tsx @@ -20,8 +20,8 @@ class Logout extends PureComponent<Props, State> { async componentDidMount() { if (typeof window !== "undefined") { - if (!this.context.client.isGuest) { - await this.context.client.logout(false); + if (!this.context.client?.isGuest) { + await this.context.client?.logout(false); } } await this.props.router.replace("/"); diff --git a/pages/post/[id].tsx b/pages/post/[id].tsx @@ -8,9 +8,9 @@ import { GetServerSideProps, InferGetServerSidePropsType } from "next"; import Head from "next/head"; import { NextRouter, withRouter } from "next/router"; import { PureComponent, ReactNode } from "react"; -import { client, ClientContext } from "../../components/ClientContext"; +import { ClientContext } from "../../components/ClientContext"; import { ImageEvent, ImageGalleryEvent, MatrixEventBase, MatrixImageEvents } from "../../helpers/event_types"; -import { constMatrixArtServer } from "../../helpers/matrix_client"; +import MatrixClient, { constMatrixArtServer } from "../../helpers/matrix_client"; import { get_data } from "../api/directory"; import { isImageEvent, isImageGalleryEvent } from '../../components/FrontPageImage'; import Link from 'next/link'; @@ -18,6 +18,7 @@ import { Blurhash } from 'react-blurhash'; import { serverSideTranslations } from 'next-i18next/serverSideTranslations'; import { i18n } from 'next-i18next'; import { toast } from 'react-toastify'; +import Storage from "../../helpers/storage"; type Props = InferGetServerSidePropsType<typeof getServerSideProps> & { router: NextRouter; @@ -88,7 +89,7 @@ class Post extends PureComponent<Props, State> { return; } console.log("hi"); - const client = this.context.client.isGuest ? this.context.client : this.context.guest_client; + const client = this.context.client?.isGuest ? this.context.client : this.context.guest_client; this.setState({ isLoadingImages: true, }); @@ -100,19 +101,30 @@ class Post extends PureComponent<Props, State> { try { roomId = await client?.followUser(user.public_user_room); } catch { + this.setState({ + isLoadingImages: false, + hasFullyLoaded: true + }); console.error("Unbable to join room"); continue; } + if (!roomId) { + this.setState({ + isLoadingImages: false, + hasFullyLoaded: true + }); + return; + } 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); - if (image_event == undefined) { + const image_event = events?.find((event) => (event.type === "m.image_gallery" || event.type === "m.image") && event.event_id === event_id); + if (!image_event) { continue; } try { - const profile = await client.getProfile(image_event.sender); + const profile = await client?.getProfile(image_event.sender); this.setState({ image_event: image_event as MatrixImageEvents, - displayname: profile.displayname ?? image_event.sender, + displayname: profile?.displayname ?? image_event.sender, }); } catch (error) { @@ -239,7 +251,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-xl text-gray-900 dark:text-gray-200 font-normal inline-flex"> - {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} + {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> {isImageEvent(image_event) ? (image_event.content['matrixart.license'] ? <h3 className='cursor-pointer mt-0 mb-4 text-l text-gray-600 dark:text-gray-400 font-normal'>{i18n?.t("License:")} <a href={this.getLicenseUrl(image_event.content['matrixart.license'])} title={this.getLicenseName(image_event.content['matrixart.license'])}>{this.getLicenseName(image_event.content['matrixart.license'])}</a></h3> : undefined) : undefined} @@ -289,7 +301,7 @@ class Post extends PureComponent<Props, State> { renderSingleImageEvent(imageEvent: ImageEvent, caption: string) { const url = this.context.client?.downloadLink(imageEvent.content["m.file"].url); - const thumbnail_url = this.context.client.thumbnailLink(imageEvent.content['m.file'].url, "scale", imageEvent.content['m.image'].width - 1, imageEvent.content['m.image'].height - 1); + const thumbnail_url = this.context.client?.thumbnailLink(imageEvent.content['m.file'].url, "scale", imageEvent.content['m.image'].width - 1, imageEvent.content['m.image'].height - 1); if (!url || !thumbnail_url) { return <></>; @@ -445,6 +457,8 @@ export const getServerSideProps: GetServerSideProps = async ({ res, locale, quer 'Cache-Control', 'public, s-maxage=10, stale-while-revalidate=59' ); + + const client = await MatrixClient.init(await Storage.init("main")); if (event_id && event_id.startsWith("$")) { try { const data = await get_data(); diff --git a/pages/profile/[userid].tsx b/pages/profile/[userid].tsx @@ -78,10 +78,10 @@ class Profile extends PureComponent<Props, State> { return; } try { - const profile = await this.context.client.getProfile(this.props.mxid); + const profile = await this.context.client?.getProfile(this.props.mxid); this.setState({ - displayname: profile.displayname ?? this.props.event.sender, - avatar_url: profile.avatar_url, + displayname: profile?.displayname ?? this.props.event.sender, + avatar_url: profile?.avatar_url, }); } catch (error) { console.debug(`Failed to fetch profile for user ${this.props.mxid}:`, error); @@ -89,7 +89,7 @@ class Profile extends PureComponent<Props, State> { await this.loadEvents(); this.setState({ - isLoggedInUser: this.props.mxid === this.context.client.userId && !this.context.client.isGuest + isLoggedInUser: this.props.mxid === this.context.client?.userId && !this.context.client.isGuest }); } @@ -112,7 +112,7 @@ class Profile extends PureComponent<Props, State> { if (isLoadingImages || hasFullyLoaded) { return; } - const client = this.context.client.isGuest ? this.context.client : this.context.guest_client; + const client = this.context.client?.isGuest ? this.context.client : this.context.guest_client; this.setState({ isLoadingImages: true, }); @@ -121,15 +121,26 @@ class Profile extends PureComponent<Props, State> { try { roomId = await client?.followUser("#" + this.props.mxid); } catch { + this.setState({ + hasFullyLoaded: true, + isLoadingImages: false + }); console.error("Unbable to join room"); return; } + if (!roomId) { + this.setState({ + hasFullyLoaded: true, + isLoadingImages: false + }); + return; + } const events = await client?.getTimeline(roomId, 100, { limit: 30, types: ["m.image", "m.image_gallery", "matrixart.profile", "matrixart.profile_banner"] }); - const profile_event = events.find((event) => event.type === "matrixart.profile" && event.sender === this.props.mxid); - const filtered_events = events.filter(event => event.type !== "m.room.member" && event.type !== "m.room.topic" && event.type !== "m.room.name" && event.type !== "m.room.power_levels"); - console.log("Adding", filtered_events.length, "items"); + const profile_event = events?.find((event) => event.type === "matrixart.profile" && event.sender === this.props.mxid); + const filtered_events = events?.filter(event => event.type !== "m.room.member" && event.type !== "m.room.topic" && event.type !== "m.room.name" && event.type !== "m.room.power_levels"); + console.log("Adding", filtered_events?.length, "items"); this.setState({ - events: filtered_events, + events: filtered_events ?? [], profile_event: profile_event as MatrixArtProfile | undefined, }); } catch (error) { @@ -171,7 +182,7 @@ class Profile extends PureComponent<Props, State> { editingUsername: true }); } else { - await this.context.client.setDisplayname(this.state.displayname); + await this.context.client?.setDisplayname(this.state.displayname); this.setState({ editingUsername: false }); @@ -185,8 +196,11 @@ class Profile extends PureComponent<Props, State> { return; } const file = files[0]; - const mxc = await this.context.client.uploadFile(file); - await this.context.client.setAvatarUrl(mxc); + const mxc = await this.context.client?.uploadFile(file); + if (!mxc) { + return; + } + await this.context.client?.setAvatarUrl(mxc); this.setState({ avatar_url: mxc, }); @@ -212,7 +226,7 @@ class Profile extends PureComponent<Props, State> { <meta property="og:type" content="website" key="og-type" /> </Head> {banner_event ? <div style={{ - backgroundImage: `url(${this.context.client.thumbnailLink((banner_event as BannerEvent).content["m.file"].url, "scale", (banner_event as BannerEvent).content["m.image"].width - 1, (banner_event as BannerEvent).content["m.image"].height - 1)})` + backgroundImage: `url(${this.context.client?.thumbnailLink((banner_event as BannerEvent).content["m.file"].url, "scale", (banner_event as BannerEvent).content["m.image"].width - 1, (banner_event as BannerEvent).content["m.image"].height - 1)})` }} className="fixed top-14 w-full h-[32.5rem] bg-cover lg:bg-[position:50%]" ></div> : undefined} @@ -231,7 +245,7 @@ class Profile extends PureComponent<Props, State> { avatar_url ? ( <> <label htmlFor="avatar-upload" className="rounded-md flex justify-center items-center cursor-pointer grayscale-0 hover:grayscale transition-all ease-in-out duration-300" style={{ height: "100px", width: "100px" }}> - <img className="block object-cover rounded-md" src={this.context.client.downloadLink(avatar_url)!} height="100" width="100" alt={displayname} title={displayname} /> + <img className="block object-cover rounded-md" src={this.context.client?.downloadLink(avatar_url)!} height="100" width="100" alt={displayname} title={displayname} /> <div className="min-h-[48px] min-w-[48px] absolute left-[20%] rounded-full bg-slate-700/40 p-1 flex justify-center items-center"><EditIcon className="invert-0 duration-300 transition-all ease-in-out hover:invert" /></div> </label> <input className="hidden" id="avatar-upload" type="file" accept="image/*" onChange={this.handleAvatarUpload.bind(this)} /> @@ -246,7 +260,7 @@ class Profile extends PureComponent<Props, State> { ) ) : (avatar_url ? - <img className="block object-cover rounded-md" src={this.context.client.downloadLink(avatar_url)!} height="100" width="100" alt={displayname} title={displayname} /> + <img className="block object-cover rounded-md" src={this.context.client?.downloadLink(avatar_url)!} height="100" width="100" alt={displayname} title={displayname} /> : <div className="rounded-md bg-slate-500 flex justify-center items-center" style={{ height: "100px", width: "100px" }}></div> ) diff --git a/pages/submit.tsx b/pages/submit.tsx @@ -31,7 +31,7 @@ class Submit extends PureComponent<Props, State> implements DropCallbacks { onDropError: (fileRejections: FileRejection[]) => void; async componentDidMount() { - if (this.context.client.isGuest) { + if (this.context.client?.isGuest) { this.props.router.replace("/login"); } }