diff --git a/.env.local.example b/.env.local.example index a24ce93..b57b47d 100644 --- a/.env.local.example +++ b/.env.local.example @@ -1,4 +1,7 @@ -NEXT_PUBLIC_DEFAULT_SERVER_URL=https://matrix.something.com -NEXT_PUBLIC_SEARCH_URL=http://127.0.0.1:7700 +VITE_MATRIX_SERVER_URL=https://matrix.something.com +VITE_MATRIX_ROOT_FOLDER=$root:something.com +VITE_MATRIX_INSTANCE_ADMIN=@admin:something.com + +VITE_SEARCH_URL=http://127.0.0.1:7700 MEILI_MASTER_KEY=xxx -NEXT_PUBLIC_MEILI_SEARCH_KEY=xxx \ No newline at end of file +VITE_MEILI_SEARCH_KEY=xxx \ No newline at end of file diff --git a/.env.test b/.env.test deleted file mode 100644 index 36df53b..0000000 --- a/.env.test +++ /dev/null @@ -1 +0,0 @@ -NEXT_PUBLIC_ENV="test" \ No newline at end of file diff --git a/.eslintignore b/.eslintignore new file mode 100644 index 0000000..b512c09 --- /dev/null +++ b/.eslintignore @@ -0,0 +1 @@ +node_modules \ No newline at end of file diff --git a/.eslintrc.json b/.eslintrc.json index cc42448..49120b2 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -1,15 +1,16 @@ { "plugins": [ - "jsx-a11y" + "tailwindcss", + "@typescript-eslint" ], + "parser": "@typescript-eslint/parser", "extends": [ - "next/core-web-vitals", - "plugin:jsx-a11y/recommended", - "plugin:unicorn/recommended" + "eslint:recommended", + "plugin:@typescript-eslint/recommended", + "plugin:unicorn/recommended", + "plugin:tailwindcss/recommended" ], "rules": { - "@next/next/no-img-element": "off", - "jsx-a11y/anchor-is-valid": "off", "unicorn/prevent-abbreviations": "off", "unicorn/filename-case": "off", "unicorn/prefer-ternary": "off", diff --git a/.github/workflows/deploy-preview.yml b/.github/workflows/deploy-preview.yml new file mode 100644 index 0000000..5f9d340 --- /dev/null +++ b/.github/workflows/deploy-preview.yml @@ -0,0 +1,75 @@ +name: Build and Deploy Preview +permissions: + actions: none + checks: none + contents: read + deployments: none + id-token: none + issues: none + discussions: none + packages: none + pages: none + pull-requests: write + repository-projects: none + security-events: none + statuses: none +on: + push: + branches: + - "main" + pull_request: +jobs: + build-and-deploy: + concurrency: ci-${{ github.ref }} + runs-on: self-hosted + steps: + - name: Edit PR Description + if: ${{ github.ref != 'refs/heads/main' }} + uses: Beakyn/gha-comment-pull-request@2167a7aee24f9e61ce76a23039f322e49a990409 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + pull-request-number: ${{ steps.readctx.outputs.prnumber }} + description-message: | + ---- + ⌛ Deploy Preview - Build in Progress + - name: Checkout 🛎️ + if: ${{ github.ref != 'refs/heads/main' }} + uses: actions/checkout@v3 + - uses: actions/setup-node@v3 + name: Setup node + if: ${{ github.ref != 'refs/heads/main' }} + with: + node-version: "16" + - name: Create env file + if: ${{ github.ref != 'refs/heads/main' }} + run: | + echo "VITE_MATRIX_SERVER_URL=https://matrix.art.midnightthoughts.space" > .env.local + echo "VITE_MATRIX_INSTANCE_ADMIN=@administrator:art.midnightthoughts.space" >> .env.local + echo "VITE_MATRIX_ROOT_FOLDER=#Matrix_Art:art.midnightthoughts.space" >> .env.local + - name: Install and Build 🔧 + if: ${{ github.ref != 'refs/heads/main' }} + run: | + npm ci + npx tsc && npx vite build --base /pr-${{ github.event.pull_request.number }}/ + - name: Deploy to gh-pages + if: ${{ github.ref != 'refs/heads/main' }} + uses: peaceiris/actions-gh-pages@v3 + with: + deploy_key: ${{ secrets.ACTIONS_DEPLOY_KEY }} + publish_dir: ./dist + destination_dir: ./pr-${{ github.event.pull_request.number }} + cname: preview.art.midnightthoughts.space + external_repository: MTRNord/matrix-art-preview + - name: Edit PR Description + if: ${{ github.ref != 'refs/heads/main' }} + uses: Beakyn/gha-comment-pull-request@2167a7aee24f9e61ce76a23039f322e49a990409 + env: + BUILD_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + description-message: | + ---- + 😎 Browse the preview: https://preview.art.midnightthoughts.space/pr-${{ github.event.pull_request.number }} ! + 🔍 Inspect the deploy log: ${{ env.BUILD_URL }} + ⚠️ Do you trust the author of this PR? Maybe this build will steal your keys or give you malware. Exercise caution. Use test accounts. diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 81bbaf8..69c8549 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -1,26 +1,27 @@ -name: Deploy to server +name: Build and Deploy +permissions: read-all on: - workflow_run: - workflows: ["Playwright Tests"] + push: branches: [main] - types: - - completed -permissions: read-all - jobs: - deploy: + build-and-deploy: + concurrency: ci-${{ github.ref }} runs-on: self-hosted steps: - # - name: Harden Runner - # uses: step-security/harden-runner@bdb12b622a910dfdc99a31fdfe6f45a16bc287a4 # v1 - # with: - # allowed-endpoints: "gitlab.nordgedanken.dev:443" - # env: - # USER: runner - - name: curl - uses: wei/curl@012398a392d02480afa2720780031f8621d5f94c # master - if: ${{ github.event.workflow_run.conclusion == 'success' }} + - name: Checkout 🛎️ + uses: actions/checkout@v3 + - uses: actions/setup-node@v3 + name: Setup node + with: + node-version: "16" + + - name: Install and Build 🔧 + run: | + npm ci + npm run build + + - name: Deploy 🚀 + uses: JamesIves/github-pages-deploy-action@v4.2.5 with: - args: -X POST -F "token=$GITLAB_TOKEN" -F "ref=main" -F "variables[GITHUB_COMMIT_ID]=$GITHUB_SHA" https://gitlab.nordgedanken.dev/api/v4/projects/2/trigger/pipeline - env: - GITLAB_TOKEN: ${{ secrets.GITLAB_TOKEN }} + branch: gh-pages # The branch the action should deploy to. + folder: dist # The folder the action should deploy. diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml deleted file mode 100644 index d7fdb59..0000000 --- a/.github/workflows/playwright.yml +++ /dev/null @@ -1,60 +0,0 @@ -name: Playwright Tests -on: - push: - branches: [main] - pull_request: - branches: [main] -permissions: read-all - -jobs: - test: - permissions: - contents: read # for actions/checkout to fetch code - timeout-minutes: 60 - runs-on: self-hosted - steps: - # - name: Harden Runner - # uses: step-security/harden-runner@bdb12b622a910dfdc99a31fdfe6f45a16bc287a4 # v1 - # with: - # allowed-endpoints: " - # github.com:22 - # github.com:443 - # api.github.com:443 - # nodejs.org:443 - # registry.npmjs.org:443 - # playwright.azureedge.net:443 - # azure.archive.ubuntu.com:443 - # azure.archive.ubuntu.com:80 - # security.ubuntu.com:80 - # packages.microsoft.com:80 - # packages.microsoft.com:443 - # ppa.launchpad.net:80 - # fonts.googleapis.com:443 - # telemetry.nextjs.org:443 - # location.services.mozilla.com:443 - # art.midnightthoughts.space:443 - # matrix.art.midnightthoughts.space:443 - # localhost:80" - # env: - # USER: runner - - uses: actions/checkout@629c2de402a417ea7690ca6ce3f33229e27606a5 # v2 - with: - persist-credentials: false - - name: Reconfigure git to use HTTP authentication - run: > - git config --global url."https://github.com/".insteadOf - ssh://git@github.com/ - - uses: actions/setup-node@17f8bd926464a1afa4c6a11669539e9c1ba77048 # v2 - with: - node-version: "17.x" - - name: Install dependencies - run: npm ci - - name: Install Playwright - run: npx playwright install --with-deps && npx playwright install msedge && npx playwright install chrome - - name: Run Playwright tests - run: npm run test - - uses: actions/upload-artifact@3cea5372237819ed00197afe530f5a7ea3e805c8 # v2 - if: always() - with: - name: playwright-test-results - path: test-results/ diff --git a/.gitignore b/.gitignore index f40901e..b4c8972 100644 --- a/.gitignore +++ b/.gitignore @@ -30,11 +30,30 @@ yarn-error.log* .env.test.local .env.production.local -# vercel -.vercel +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* -# typescript -*.tsbuildinfo +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? #pouchdb /matrix-art-db diff --git a/Dockerfile b/Dockerfile deleted file mode 100644 index 33018c2..0000000 --- a/Dockerfile +++ /dev/null @@ -1,49 +0,0 @@ -# Install dependencies only when needed -# Use node:17-slim -FROM node@sha256:5e1c50b7686bcaf01b800966bf52d83a2530ea521290bba6eb0fd4eae3025055 AS deps -# for some reason the $PATH get lost inside kaniko, if we re-set it by hand it seems to work. -ENV PATH="$PATH:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" -WORKDIR /app -COPY package.json package-lock.json ./ -RUN npm ci - -# Rebuild the source code only when needed -# Use node:17-slim -FROM node@sha256:5e1c50b7686bcaf01b800966bf52d83a2530ea521290bba6eb0fd4eae3025055 AS builder -WORKDIR /app -COPY . . -COPY --from=deps /app/node_modules ./node_modules -RUN npx browserslist@latest --update-db -RUN mkdir ./localstorage && echo "4" >> ./localstorage/version -RUN npm run build && npm install --only=production --ignore-scripts --prefer-offline - -# Production image, copy all the files and run next -# Use node:17-slim -FROM node@sha256:5e1c50b7686bcaf01b800966bf52d83a2530ea521290bba6eb0fd4eae3025055 AS runner -WORKDIR /app - -ENV NODE_ENV production - -RUN addgroup --gid 1001 --system nodejs -RUN adduser --system nextjs --uid 1001 - -# You only need to copy next.config.js if you are NOT using the default configuration -COPY --from=builder /app/public ./public -COPY --from=builder --chown=nextjs:nodejs /app/.next ./.next -COPY --from=builder /app/node_modules ./node_modules -COPY --from=builder /app/package.json ./package.json -COPY --from=builder /app/next.config.js ./next.config.js -COPY --from=builder /app/next-i18next.config.js ./next-i18next.config.js - -USER nextjs - -EXPOSE 3000 - -ENV PORT 3000 - -# Next.js collects completely anonymous telemetry data about general usage. -# Learn more here: https://nextjs.org/telemetry -# Uncomment the following line in case you want to disable telemetry. -ENV NEXT_TELEMETRY_DISABLED 1 - -CMD ["node_modules/.bin/next", "start"] diff --git a/components/ClientContext.tsx b/components/ClientContext.tsx deleted file mode 100644 index 051380a..0000000 --- a/components/ClientContext.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import React, { Context } from "react"; -import MatrixClient from "../helpers/matrix_client"; - -type ContextData = { - client?: MatrixClient; - guest_client?: MatrixClient; - is_generating_guest: boolean; -}; - -const ClientContext: Context = React.createContext({ - client: undefined, - guest_client: undefined, - is_generating_guest: false -}); - -export { ClientContext }; \ No newline at end of file diff --git a/components/Footer.tsx b/components/Footer.tsx deleted file mode 100644 index f622760..0000000 --- a/components/Footer.tsx +++ /dev/null @@ -1,15 +0,0 @@ -import Link from "next/link"; -import { PureComponent } from "react"; -import { i18n } from 'next-i18next'; - -export default class Footer extends PureComponent { - render() { - return ( - - ); - } -} \ No newline at end of file diff --git a/components/FrontPageImage.tsx b/components/FrontPageImage.tsx deleted file mode 100644 index 9b06121..0000000 --- a/components/FrontPageImage.tsx +++ /dev/null @@ -1,178 +0,0 @@ -import Link from "next/link"; -import { PureComponent } from "react"; -import { Blurhash } from "react-blurhash"; -import { ImageEvent, ImageGalleryEvent, MatrixEventBase, MatrixImageEvents } from "../helpers/event_types"; -import { constMatrixArtServer } from "../helpers/matrix_client"; -import { ClientContext } from "./ClientContext"; -import PropTypes from 'prop-types'; -import { toast } from "react-toastify"; -import { i18n } from "next-i18next"; - -type Props = { - event: MatrixImageEvents; - imageHeight?: string; - show_nsfw: boolean; -}; - -type State = { - displayname: string; - imageHeight?: string; - error?: string; -}; - -export default class FrontPageImage extends PureComponent { - declare context: React.ContextType; - - constructor(props: Props) { - super(props); - - this.state = { - displayname: this.props.event.sender, - imageHeight: this.props.imageHeight ? this.props.imageHeight : "270px" - } as State; - } - - static propTypes = { - event: PropTypes.object, - imageHeight: PropTypes.string, - show_nsfw: PropTypes.bool - }; - - componentDidUpdate(prevProps: Props, prevState: State) { - if (this.state.error && this.state.error !== prevState.error) { - toast(() =>

{i18n?.t("Error")}


{this.state.error}
, { - autoClose: false - }); - } - } - - async componentDidMount() { - // auto-register as a guest if not logged in - if (!this.context.client?.accessToken) { - this.registerAsGuest();; - } else { - console.log("Already logged in"); - if (!this.props.event) { - return; - } - try { - const profile = await this.context.client.getProfile(this.props.event.sender); - this.setState({ - displayname: profile.displayname ?? this.props.event.sender, - }); - } catch (error) { - console.debug(`Failed to fetch profile for user ${this.props.event.sender}:`, error); - } - } - - } - - async registerAsGuest() { - if (this.context.is_generating_guest) { - return; - } - this.context.is_generating_guest = true; - try { - let serverUrl = constMatrixArtServer + "/_matrix/client"; - await this.context.client?.registerAsGuest(serverUrl); - this.context.is_generating_guest = false; - } catch (error) { - console.error("Failed to register as guest:", error); - this.setState({ - error: "Failed to register as guest: " + JSON.stringify(error), - }); - } - this.context.is_generating_guest = false; - } - - render() { - const event = this.props.event; - return ( - <> - {isImageGalleryEvent(event) ? this.render_gallery(event) : (isImageEvent(event) ? this.render_image(event) :
)} - - ); - } - - - render_gallery(event: ImageGalleryEvent) { - const caption_text = event.content['m.caption'].filter(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_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]; - return event.content['m.image_gallery'].map(image => { - if (image["matrixart.nsfw"] && !this.props.show_nsfw) { - return; - } - const thumbnail_url = image['m.thumbnail'] ? (image['m.thumbnail'].length > 0 ? image['m.thumbnail'][0].url : image['m.file'].url) : image['m.file'].url; - return this.render_image_box(thumbnail_url, event.event_id + image['m.file'].url, event.event_id, caption_text, image['m.image'].height, image['m.image'].width, image["xyz.amorgan.blurhash"]); - }); - } - - - render_image(event: ImageEvent) { - if (event.content["matrixart.nsfw"] && !this.props.show_nsfw) { - return; - } - const caption_text = event.content['m.caption'].filter(cap => { - const possible_html_caption = (cap as { body: string; mimetype: string; }); - 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) { - // TODO show creators display name instead of mxid and show avatar image - // TODO proper alt text - const direct_link = `/post/${encodeURIComponent(post_id)}`; - const image = blurhash ? ( -
- - {caption} -
- ) : ( - {caption} - ); - return ( -
  • - - -
    - {image} -
    -

    {caption}

    -

    {this.state.displayname}

    -
    -
    -
    - -
  • - ); - } -} -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.unsigned?.redacted_because; -} - - -export function isImageEvent(event: MatrixImageEvents): event is ImageEvent { - return event.type === "m.image" && !event.unsigned?.redacted_because; -}; \ No newline at end of file diff --git a/components/Header.tsx b/components/Header.tsx deleted file mode 100644 index 4637c95..0000000 --- a/components/Header.tsx +++ /dev/null @@ -1,141 +0,0 @@ -import Link from "next/link"; -import { PureComponent } from "react"; -import User from "../helpers/db/Users"; -import { ClientContext } from "./ClientContext"; -import { i18n } from 'next-i18next'; -import { toast } from "react-toastify"; -import dynamic from "next/dynamic"; - -type Props = { -}; - -type State = { - directory_data: User[]; - loading: boolean; - error?: string; -}; -export default class Header extends PureComponent { - declare context: React.ContextType; - constructor(props: Props) { - super(props); - - this.state = { - directory_data: [], - loading: true - } as State; - } - - componentDidUpdate(prevProps: Props, prevState: State) { - if (this.state.error && this.state.error !== prevState.error) { - toast(() =>

    {i18n?.t("Error")}


    {this.state.error}
    , { - autoClose: false - }); - } - } - - async componentDidMount() { - try { - const data = await fetch("/api/directory", { method: "GET" }); - const data_parsed = await data.json(); - const directory_data = data_parsed.data; - this.setState({ directory_data: directory_data, loading: false }); - } catch { - console.error("Failed to get directory"); - this.setState({ error: "Failed to get directory" }); - } - } - render() { - if (this.state.loading) { - return <>; - } - return ( - <> -
    - - - - - -
    -
    -
    -
    - - - - -
    -
    -
    - - -
    - - - {value => { - const isGuest = value.client?.isGuest === null || value.client?.isGuest === undefined ? true : value.client.isGuest; - if (typeof window !== "undefined" && !isGuest) { - return (); - } else { - return (); - } - }} - - -
    -
    - - {value => { - 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)) { - return {i18n?.t('Submit') ?? 'Submit'}; - } else { - return {i18n?.t('Setup Account') ?? 'Setup Account'}; - } - } - }} - -
    -
    -
    - - ); - } -} -Header.contextType = ClientContext; \ No newline at end of file diff --git a/components/editIcon.tsx b/components/editIcon.tsx deleted file mode 100644 index 8419146..0000000 --- a/components/editIcon.tsx +++ /dev/null @@ -1,37 +0,0 @@ -import { PureComponent } from "react"; -import PropTypes from 'prop-types'; - -type Props = { - onClick?: () => void; - className?: string; -}; - -type State = { - className: string; - onClick: () => void; -}; - -export class EditIcon extends PureComponent { - - constructor(props: Props) { - super(props); - - this.state = { - className: (props.className ?? "") + " dark:fill-white fill-black", - onClick: props.onClick ?? (() => {/*noop*/ }) - }; - } - static propTypes = { - onClick: PropTypes.func, - className: PropTypes.string - }; - - render() { - return ( - - - - - ); - } -} \ No newline at end of file diff --git a/components/submit_page/main.tsx b/components/submit_page/main.tsx deleted file mode 100644 index c01754f..0000000 --- a/components/submit_page/main.tsx +++ /dev/null @@ -1,556 +0,0 @@ -import { PureComponent } 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, ThumbnailData } from "../../helpers/event_types"; -import { toast } from "react-toastify"; -// @ts-ignore This has no types -import extractPngChunks from "png-chunks-extract"; -import { i18n } from "next-i18next"; - -type ThumbnailableElement = HTMLImageElement | HTMLVideoElement; -type ThumbnailTransmissionData = { - thumbnail_meta: ThumbnailData; - thumbnail: Blob; -}; -const MAX_WIDTH = 800; -const MAX_HEIGHT = 600; - -// scraped out of a macOS hidpi (5660ppm) screenshot png -// 5669 px (x-axis) , 5669 px (y-axis) , per metre -const PHYS_HIDPI = [0x00, 0x00, 0x16, 0x25, 0x00, 0x00, 0x16, 0x25, 0x01]; - -// Minimum size for image files before we generate a thumbnail for them. -const IMAGE_SIZE_THRESHOLD_THUMBNAIL = 1 << 15; // 32KB -// Minimum size improvement for image thumbnails, if both are not met then don't bother uploading thumbnail. -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[]; -}; - -type State = { - currentFileIndex: number; - hasSubmit: boolean; - hasBack: boolean; - submit_in_process: boolean; - [key: string]: any; -}; -class MainSubmissionForm extends PureComponent { - declare context: React.ContextType; - - constructor(props: Props) { - super(props); - this.state = { - currentFileIndex: 0, - hasSubmit: props.files.length === 1 ? true : false, - hasBack: false, - submit_in_process: false, - }; - const range = [...Array(this.props.files.length).keys()]; // eslint-disable-line unicorn/new-for-builtins - for (const index of range) { - // @ts-ignore Its fine in the constructor - this.state[`${index}_valid`] = false; - } - } - static propTypes = { - onDrop: PropTypes.func, - onDropError: PropTypes.func, - files: PropTypes.array, - hasSubmit: PropTypes.bool, - hasBack: PropTypes.bool - }; - - renderThumb() { - const file = this.props.files[this.state.currentFileIndex]; - return ( -
    - {file.name} -
    - ); - } - - renderThumbs() { - return this.props.files.map((file, index) => { - const setIndex = () => { - if (this.props.files.length - 1 > index) { - const hasBack = index > 0 ? true : false; - this.setState({ currentFileIndex: index, hasBack, hasSubmit: false }); - } else if (this.props.files.length - 1 === index) { - const hasBack = index > 0 ? true : false; - this.setState({ currentFileIndex: index, hasBack, hasSubmit: true }); - } - }; - - const classes = () => { - const classes_base = ["cursor-pointer", "aspect-video", "my-2"]; - - if (this.state.currentFileIndex == index) { - classes_base.push("border", "p-2", "border-2", "border-white"); - } else if (this.state[`${index}_valid`]) { - classes_base.push("border", "p-2", "border-2", "border-teal-600"); - } else { - classes_base.push("border", "p-2", "border-2", "border-yellow-700"); - } - return classes_base.join(" "); - }; - - - // TODO FIXME do make this work with keyboard presses! - /* eslint-disable jsx-a11y/click-events-have-key-events */ - return ( -
    - {file.name} -
    - ); - /* eslint-enable jsx-a11y/click-events-have-key-events */ - }); - } - - onNext(ev: { preventDefault: () => void; }) { - ev.preventDefault(); - const newIndex = this.state.currentFileIndex + 1; - if (this.props.files.length - 1 > newIndex) { - const hasBack = newIndex > 0 ? true : false; - this.setState({ currentFileIndex: newIndex, hasBack }); - } else if (this.props.files.length - 1 === newIndex) { - this.setState({ currentFileIndex: newIndex, hasSubmit: true }); - } - } - - onPrev(ev: { preventDefault: () => void; }) { - ev.preventDefault(); - const newIndex = this.state.currentFileIndex - 1; - if (this.props.files.length - 1 > newIndex) { - const hasBack = newIndex > 0 ? true : false; - this.setState({ currentFileIndex: newIndex, hasBack }); - } else if (this.props.files.length - 1 === newIndex) { - this.setState({ currentFileIndex: newIndex, hasSubmit: true }); - } - } - - - async handleSubmit() { - if (this.state.submit_in_process) { - return; - } - this.setState({ submit_in_process: true }); - - const range = [...Array(this.props.files.length).keys()]; // eslint-disable-line unicorn/new-for-builtins - const posts_for_search: SearchMedia[] = []; - - // Clear all errors before showing new - toast.dismiss(); - - - if (this.context.client?.isGuest) { - toast.error(() =>

    {i18n?.t("Error")}


    {i18n?.t("You are not logged in!")}
    , { - autoClose: false - }); - this.setState({ submit_in_process: false }); - return; - } - - // If any image is invalid do exit submit for now. - for (const index of range) { - const valid = this.state[`${index}_valid`]; - if (!valid) { - toast.error(() =>

    {i18n?.t("Error")}


    {i18n?.t("You did not fill the required fields for all images. Please fix this!")}
    , { - autoClose: false - }); - this.setState({ submit_in_process: false }); - return; - } - } - - const image_infos = await this.getImageInfos(); - const ids = await this.doUpload(); - - const thumbnails = await this.generateThumbnailsAndUpload(image_infos); - - // Handle uploads - for (const index of range) { - const title = `${index}_title`; - const description = `${index}_description`; - const tags = `${index}_tags`; - const license = `${index}_license`; - const nsfw = `${index}_nsfw`; - const file = this.props.files[index]; - - if (!this.context.client?.profileRoomId) { - toast.error(() =>

    {i18n?.t("Error")}


    {i18n?.t("Unable to find your profile Room. Please log in again!")}
    , { - autoClose: false - }); - this.setState({ submit_in_process: false }); - 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: image_infos[index].height, - width: image_infos[index].width, - }, - "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.setState({ submit_in_process: false }); - } - - /** - * Read the file as an ArrayBuffer. - * @param {File} file The file to read - * @return {Promise} A promise that resolves with an ArrayBuffer when the file - * is read. - */ - private async readFileAsArrayBuffer(file: File | Blob): Promise { - return new Promise((resolve, reject) => { - const reader = new FileReader(); - reader.addEventListener("load", (e: ProgressEvent) => { - resolve(e.target?.result as ArrayBuffer); - }); - reader.addEventListener("error", (e) => { - reject(e); - }); - reader.readAsArrayBuffer(file); - }); - } - - private async getImageInfos() { - const image_infos = []; - const range = [...Array(this.props.files.length).keys()]; // eslint-disable-line unicorn/new-for-builtins - for (const index of range) { - const imageFile = this.props.files[index]; - // Load the file into an html element - const img = document.createElement("img"); - const objectUrl = URL.createObjectURL(imageFile); - const imgPromise = new Promise((resolve, reject) => { - img.addEventListener("load", () => { - URL.revokeObjectURL(objectUrl); - resolve(img); - }); - img.addEventListener("error", (e) => { - reject(e); - }); - }); - img.src = objectUrl; - - // check for hi-dpi PNGs and fudge display resolution as needed. - // this is mainly needed for macOS screencaps - let parsePromise; - if (imageFile.type === "image/png") { - // in practice macOS happens to order the chunks so they fall in - // the first 0x1000 bytes (thanks to a massive ICC header). - // Thus we could slice the file down to only sniff the first 0x1000 - // bytes (but this makes extractPngChunks choke on the corrupt file) - const headers = imageFile; //.slice(0, 0x1000); - parsePromise = this.readFileAsArrayBuffer(headers).then(arrayBuffer => { - const buffer = new Uint8Array(arrayBuffer); - const chunks = extractPngChunks(buffer); - for (const chunk of chunks) { - if (chunk.name === 'pHYs') { - if (chunk.data.byteLength !== PHYS_HIDPI.length) return; - return chunk.data.every((val: number, i: number) => val === PHYS_HIDPI[i]); - } - } - return false; - }); - } - - const [hidpi] = await Promise.all([parsePromise, imgPromise]); - const width = hidpi ? (img.width >> 1) : img.width; - const height = hidpi ? (img.height >> 1) : img.height; - image_infos.push({ width, height, img }); - } - return image_infos; - } - - // This is taken from matrix-react-sdk commit efa1667d7e9de9e429a72396a5105d0219006db2 - private async createThumbnail( - element: ThumbnailableElement, - inputWidth: number, - inputHeight: number, - mimeType: string - ): Promise { - 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; - - if (window.OffscreenCanvas) { - thumbnailPromise = (canvas as OffscreenCanvas).convertToBlob({ type: mimeType }); - } else { - thumbnailPromise = new Promise(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(image_infos: { width: number; height: number; img: HTMLImageElement; }[]): Promise<{ index: number; meta: ThumbnailData; }[]> { - const thumbnails = []; - if (!this.context.client?.isGuest) { - const range = [...Array(this.props.files.length).keys()]; // eslint-disable-line unicorn/new-for-builtins - for (const index of range) { - const file = this.props.files[index]; - const thumbnail_data = await this.createThumbnail( - image_infos[index].img, - image_infos[index].width, - image_infos[index].height, - file.type - ); - if (!thumbnail_data) { - 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); - if (result) { - 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) { - 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; - } - - componentDidUpdate() { - const title = `${this.state.currentFileIndex}_title`; - const description = `${this.state.currentFileIndex}_description`; - const license = `${this.state.currentFileIndex}_license`; - const nsfw = `${this.state.currentFileIndex}_nsfw`; - let validated = false; - if (this.state[title] && this.state[license] && this.state[description] && this.state[nsfw]) { - validated = true; - } - if (this.state[`${this.state.currentFileIndex}_valid`] !== validated) { - this.setState({ - [`${this.state.currentFileIndex}_valid`]: validated - } as State); - } - } - - handleInputChange(event: { target: any; }) { - const target = event.target; - const value = target.type === 'checkbox' ? target.checked : target.value; - const name = `${this.state.currentFileIndex}_${target.name}`; - - this.setState({ - [name]: value, - } as State); - } - - render() { - if (this.state.submit_in_process) { - return ( -
    -
    -
    {i18n?.t('Loading')}...
    -
    -
    - ); - } - return ( -
    -
    -
    - {this.renderThumb()} -
    -
    -
    - - - - -