From 0b2d488bbe749d052de340c844f18469df43e6c0 Mon Sep 17 00:00:00 2001
From: enes <enes@nostrdev.com>
Date: Tue, 7 Jan 2025 09:46:28 +0100
Subject: [PATCH] feat(image): add controller, media services and error
 handling

---
 src/components/Inputs.tsx                  | 159 -----------
 src/components/Inputs/index.tsx            | 299 +++++++++++++++++++++
 src/controllers/image/index.ts             |  74 +++++
 src/controllers/image/nostrcheck-server.ts | 162 +++++++++++
 src/controllers/image/route96.ts           |   7 +
 src/controllers/index.ts                   |   1 +
 src/types/errors.ts                        |  58 ++++
 src/types/index.ts                         |   1 +
 8 files changed, 602 insertions(+), 159 deletions(-)
 delete mode 100644 src/components/Inputs.tsx
 create mode 100644 src/components/Inputs/index.tsx
 create mode 100644 src/controllers/image/index.ts
 create mode 100644 src/controllers/image/nostrcheck-server.ts
 create mode 100644 src/controllers/image/route96.ts
 create mode 100644 src/types/errors.ts

diff --git a/src/components/Inputs.tsx b/src/components/Inputs.tsx
deleted file mode 100644
index 530fe53..0000000
--- a/src/components/Inputs.tsx
+++ /dev/null
@@ -1,159 +0,0 @@
-import React from 'react'
-import '../styles/styles.css'
-
-interface InputFieldProps {
-  label: string | React.ReactElement
-  description?: string
-  type?: 'text' | 'textarea'
-  placeholder: string
-  name: string
-  inputMode?: 'url'
-  value: string
-  error?: string
-  onChange: (name: string, value: string) => void
-}
-
-export const InputField = React.memo(
-  ({
-    label,
-    description,
-    type = 'text',
-    placeholder,
-    name,
-    inputMode,
-    value,
-    error,
-    onChange
-  }: InputFieldProps) => {
-    const handleChange = (
-      e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>
-    ) => {
-      onChange(name, e.target.value)
-    }
-
-    return (
-      <div className='inputLabelWrapperMain'>
-        <label className='form-label labelMain'>{label}</label>
-        {description && <p className='labelDescriptionMain'>{description}</p>}
-        {type === 'textarea' ? (
-          <textarea
-            className='inputMain'
-            placeholder={placeholder}
-            name={name}
-            value={value}
-            onChange={handleChange}
-          ></textarea>
-        ) : (
-          <input
-            type={type}
-            className='inputMain'
-            placeholder={placeholder}
-            name={name}
-            inputMode={inputMode}
-            value={value}
-            onChange={handleChange}
-          />
-        )}
-        {error && <InputError message={error} />}
-      </div>
-    )
-  }
-)
-
-type InputErrorProps = {
-  message: string
-}
-
-export const InputError = ({ message }: InputErrorProps) => {
-  if (!message) return null
-
-  return (
-    <div className='errorMain'>
-      <div className='errorMainColor'></div>
-      <p className='errorMainText'>{message}</p>
-    </div>
-  )
-}
-
-interface CheckboxFieldProps {
-  label: string
-  name: string
-  isChecked: boolean
-  handleChange: (e: React.ChangeEvent<HTMLInputElement>) => void
-  type?: 'default' | 'stylized'
-}
-
-export const CheckboxField = React.memo(
-  ({
-    label,
-    name,
-    isChecked,
-    handleChange,
-    type = 'default'
-  }: CheckboxFieldProps) => (
-    <div
-      className={`inputLabelWrapperMain inputLabelWrapperMainAlt${
-        type === 'stylized' ? ` inputLabelWrapperMainAltStylized` : ''
-      }`}
-    >
-      <label htmlFor={name} className='form-label labelMain'>
-        {label}
-      </label>
-      <input
-        id={name}
-        type='checkbox'
-        className='CheckboxMain'
-        name={name}
-        checked={isChecked}
-        onChange={handleChange}
-      />
-    </div>
-  )
-)
-
-interface InputFieldUncontrolledProps extends React.ComponentProps<'input'> {
-  label: string | React.ReactElement
-  description?: string
-  error?: string
-}
-/**
- * Uncontrolled input component with design classes, label, description and error support
- *
- * Extends {@link React.ComponentProps<'input'> React.ComponentProps<'input'>}
- * @param label
- * @param description
- * @param error
- *
- * @see {@link React.ComponentProps<'input'>}
- */
-export const InputFieldUncontrolled = ({
-  label,
-  description,
-  error,
-  ...rest
-}: InputFieldUncontrolledProps) => (
-  <div className='inputLabelWrapperMain'>
-    <label htmlFor={rest.id} className='form-label labelMain'>
-      {label}
-    </label>
-    {description && <p className='labelDescriptionMain'>{description}</p>}
-    <input className='inputMain' {...rest} />
-    {error && <InputError message={error} />}
-  </div>
-)
-
-interface CheckboxFieldUncontrolledProps extends React.ComponentProps<'input'> {
-  label: string
-}
-
-export const CheckboxFieldUncontrolled = ({
-  label,
-  ...rest
-}: CheckboxFieldUncontrolledProps) => (
-  <div className='inputLabelWrapperMain inputLabelWrapperMainAlt inputLabelWrapperMainAltStylized'>
-    <label htmlFor={rest.id} className='form-label labelMain'>
-      {label}
-    </label>
-    <input type='checkbox' className='CheckboxMain' {...rest} />
-  </div>
-)
diff --git a/src/components/Inputs/index.tsx b/src/components/Inputs/index.tsx
new file mode 100644
index 0000000..61c6774
--- /dev/null
+++ b/src/components/Inputs/index.tsx
@@ -0,0 +1,299 @@
+import React, { useCallback, useMemo, useState } from 'react'
+import { useDropzone } from 'react-dropzone'
+import {
+  ImageController,
+  MEDIA_DROPZONE_OPTIONS,
+  MEDIA_OPTIONS,
+  MediaOption
+} from '../../controllers'
+import '../../styles/styles.css'
+import { errorFeedback } from '../../types'
+import { InputError } from './Error'
+import { MediaInputPopover } from './MediaInputPopover'
+
+interface InputFieldProps {
+  label: string | React.ReactElement
+  description?: string
+  type?: 'text' | 'textarea'
+  placeholder: string
+  name: string
+  inputMode?: 'url'
+  value: string
+  error?: string
+  onChange: (name: string, value: string) => void
+}
+
+export const InputField = React.memo(
+  ({
+    label,
+    description,
+    type = 'text',
+    placeholder,
+    name,
+    inputMode,
+    value,
+    error,
+    onChange
+  }: InputFieldProps) => {
+    const handleChange = (
+      e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>
+    ) => {
+      onChange(name, e.target.value)
+    }
+
+    return (
+      <div className='inputLabelWrapperMain'>
+        <label className='form-label labelMain'>{label}</label>
+        {description && <p className='labelDescriptionMain'>{description}</p>}
+        {type === 'textarea' ? (
+          <textarea
+            className='inputMain'
+            placeholder={placeholder}
+            name={name}
+            value={value}
+            onChange={handleChange}
+          ></textarea>
+        ) : (
+          <input
+            type={type}
+            className='inputMain'
+            placeholder={placeholder}
+            name={name}
+            inputMode={inputMode}
+            value={value}
+            onChange={handleChange}
+          />
+        )}
+        {error && <InputError message={error} />}
+      </div>
+    )
+  }
+)
+
+interface CheckboxFieldProps {
+  label: string
+  name: string
+  isChecked: boolean
+  handleChange: (e: React.ChangeEvent<HTMLInputElement>) => void
+  type?: 'default' | 'stylized'
+}
+
+export const CheckboxField = React.memo(
+  ({
+    label,
+    name,
+    isChecked,
+    handleChange,
+    type = 'default'
+  }: CheckboxFieldProps) => (
+    <div
+      className={`inputLabelWrapperMain inputLabelWrapperMainAlt${
+        type === 'stylized' ? ` inputLabelWrapperMainAltStylized` : ''
+      }`}
+    >
+      <label htmlFor={name} className='form-label labelMain'>
+        {label}
+      </label>
+      <input
+        id={name}
+        type='checkbox'
+        className='CheckboxMain'
+        name={name}
+        checked={isChecked}
+        onChange={handleChange}
+      />
+    </div>
+  )
+)
+
+interface InputFieldUncontrolledProps extends React.ComponentProps<'input'> {
+  label: string | React.ReactElement
+  description?: string
+  error?: string
+}
+/**
+ * Uncontrolled input component with design classes, label, description and error support
+ *
+ * Extends {@link React.ComponentProps<'input'> React.ComponentProps<'input'>}
+ * @param label
+ * @param description
+ * @param error
+ *
+ * @see {@link React.ComponentProps<'input'>}
+ */
+export const InputFieldUncontrolled = ({
+  label,
+  description,
+  error,
+  ...rest
+}: InputFieldUncontrolledProps) => (
+  <div className='inputLabelWrapperMain'>
+    <label htmlFor={rest.id} className='form-label labelMain'>
+      {label}
+    </label>
+    {description && <p className='labelDescriptionMain'>{description}</p>}
+    <input className='inputMain' {...rest} />
+    {error && <InputError message={error} />}
+  </div>
+)
+
+interface CheckboxFieldUncontrolledProps extends React.ComponentProps<'input'> {
+  label: string
+}
+
+export const CheckboxFieldUncontrolled = ({
+  label,
+  ...rest
+}: CheckboxFieldUncontrolledProps) => (
+  <div className='inputLabelWrapperMain inputLabelWrapperMainAlt inputLabelWrapperMainAltStylized'>
+    <label htmlFor={rest.id} className='form-label labelMain'>
+      {label}
+    </label>
+    <input type='checkbox' className='CheckboxMain' {...rest} />
+  </div>
+)
+
+interface InputFieldWithImageUpload {
+  label: string | React.ReactElement
+  description?: string
+  multiple?: boolean | undefined
+  placeholder: string
+  name: string
+  inputMode?: 'url'
+  value: string
+  error?: string
+  onChange: (name: string, value: string) => void
+}
+export const InputFieldWithImageUpload = React.memo(
+  ({
+    label,
+    description,
+    multiple = false,
+    placeholder,
+    name,
+    inputMode,
+    value,
+    error,
+    onChange
+  }: InputFieldWithImageUpload) => {
+    const [mediaOption, setMediaOption] = useState<MediaOption>(
+      MEDIA_OPTIONS[0]
+    )
+    const handleOptionChange = useCallback(
+      (mo: MediaOption) => () => {
+        setMediaOption(mo)
+      },
+      []
+    )
+    const handleChange = useCallback(
+      (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
+        onChange(name, e.target.value)
+      },
+      [name, onChange]
+    )
+    const handleUpload = useCallback(
+      async (acceptedFiles: File[]) => {
+        try {
+          const imageController = new ImageController(mediaOption)
+          const url = await imageController.post(acceptedFiles[0])
+          onChange(name, url)
+        } catch (error) {
+          errorFeedback(error)
+        }
+      },
+      [mediaOption, name, onChange]
+    )
+    const {
+      getRootProps,
+      getInputProps,
+      isDragActive,
+      acceptedFiles,
+      isFileDialogActive,
+      isDragAccept,
+      isDragReject,
+      fileRejections
+    } = useDropzone({
+      ...MEDIA_DROPZONE_OPTIONS,
+      onDrop: handleUpload,
+      multiple: multiple
+    })
+
+    const dropzoneLabel = useMemo(
+      () =>
+        isFileDialogActive
+          ? 'Select files in dialog'
+          : isDragActive
+          ? isDragAccept
+            ? 'Drop the files here...'
+            : isDragReject
+            ? 'Drop the files here (one more more unsupported types)...'
+            : 'TODO'
+          : 'Click or drag files here',
+      [isDragAccept, isDragActive, isDragReject, isFileDialogActive]
+    )
+
+    return (
+      <div className='inputLabelWrapperMain'>
+        <label className='form-label labelMain'>{label}</label>
+        {typeof description !== 'undefined' && (
+          <p className='labelDescriptionMain'>{description}</p>
+        )}
+
+        <div aria-label='upload featuredImageUrl' className='uploadBoxMain'>
+          <MediaInputPopover
+            name={name}
+            acceptedFiles={acceptedFiles}
+            fileRejections={fileRejections}
+          />
+          <div
+            className='uploadBoxMainInside'
+            {...getRootProps()}
+            tabIndex={-1}
+          >
+            <input id='featuredImageUrl-upload' {...getInputProps()} />
+
+            <span>{dropzoneLabel}</span>
+            <div
+              className='FiltersMainElement'
+              onClick={(e) => e.stopPropagation()}
+            >
+              <div className='dropdown dropdownMain'>
+                <button
+                  className='btn dropdown-toggle btnMain btnMainDropdown'
+                  aria-expanded='false'
+                  data-bs-toggle='dropdown'
+                  type='button'
+                >
+                  Image Host: {mediaOption.name}
+                </button>
+                <div className='dropdown-menu dropdownMainMenu'>
+                  {MEDIA_OPTIONS.map((mo) => {
+                    return (
+                      <div
+                        key={mo.host}
+                        onClick={handleOptionChange(mo)}
+                        className='dropdown-item dropdownMainMenuItem'
+                      >
+                        {mo.name}
+                      </div>
+                    )
+                  })}
+                </div>
+              </div>
+            </div>
+          </div>
+        </div>
+        <input
+          type='text'
+          className='inputMain'
+          placeholder={placeholder}
+          name={name}
+          inputMode={inputMode}
+          value={value}
+          onChange={handleChange}
+        />
+        {error && <InputError message={error} />}
+      </div>
+    )
+  }
+)
diff --git a/src/controllers/image/index.ts b/src/controllers/image/index.ts
new file mode 100644
index 0000000..d1d362d
--- /dev/null
+++ b/src/controllers/image/index.ts
@@ -0,0 +1,74 @@
+import { DropzoneOptions } from 'react-dropzone'
+import { NostrCheckServer } from './nostrcheck-server'
+import { BaseError } from 'types'
+
+export interface MediaOperations {
+  post: (file: File) => Promise<string>
+}
+export type MediaStrategy = Omit<MediaOperations, 'auth'>
+
+export interface MediaOption {
+  name: string
+  host: string
+  type: 'nostrcheck-server' | 'route96'
+}
+
+// nostr.build based dropzone options
+export const MEDIA_DROPZONE_OPTIONS: DropzoneOptions = {
+  maxSize: 7000000,
+  accept: {
+    'image/*': ['.jpeg', '.png', '.jpg', '.gif', '.webp']
+  }
+}
+
+export const MEDIA_OPTIONS: MediaOption[] = [
+  // {
+  //   name: 'nostr.build',
+  //   host: 'https://nostr.build/',
+  //   type: 'nostrcheck-server'
+  // },
+  {
+    name: 'nostrcheck.me',
+    host: 'https://nostrcheck.me/',
+    type: 'nostrcheck-server'
+  },
+  {
+    name: 'nostpic.com',
+    host: 'https://nostpic.com/',
+    type: 'nostrcheck-server'
+  },
+  {
+    name: 'files.sovbit.host',
+    host: 'https://files.sovbit.host/',
+    type: 'nostrcheck-server'
+  }
+  // {
+  //   name: 'void.cat',
+  //   host: 'https://void.cat/',
+  //   type: 'route96'
+  // }
+]
+
+enum ImageErrorType {
+  'TYPE_MISSING' = 'Media Option must include a type.'
+}
+
+export class ImageController implements MediaStrategy {
+  post: (file: File) => Promise<string>
+
+  constructor(mediaOption: MediaOption) {
+    let strategy: MediaStrategy
+    switch (mediaOption.type) {
+      case 'nostrcheck-server':
+        strategy = new NostrCheckServer(mediaOption.host)
+        this.post = strategy.post
+        break
+
+      case 'route96':
+        throw new Error('Not implemented.')
+
+      default:
+        throw new BaseError(ImageErrorType.TYPE_MISSING)
+    }
+  }
+}
diff --git a/src/controllers/image/nostrcheck-server.ts b/src/controllers/image/nostrcheck-server.ts
new file mode 100644
index 0000000..fec22f3
--- /dev/null
+++ b/src/controllers/image/nostrcheck-server.ts
@@ -0,0 +1,162 @@
+import axios, { isAxiosError } from 'axios'
+import { NostrEvent, NDKKind } from '@nostr-dev-kit/ndk'
+import { type MediaOperations } from '.'
+import { store } from 'store'
+import { now } from 'utils'
+import { BaseError, handleError } from 'types'
+
+// https://github.com/quentintaranpino/nostrcheck-server/blob/main/DOCS.md#media-post
+// Response object (other fields omitted for brevity)
+// {
+//     "status": "success",
+//     "nip94_event": {
+//         "tags": [
+//             [
+//                 "url",
+//                 "https://nostrcheck.me/media/62c76eb094369d938f5895442eef7f53ebbf019f69707d64e77d4d182b609309/c35277dbcedebb0e3b80361762c8baadb66dcdfb6396949e50630159a472c3b2.webp"
+//             ],
+//         ],
+//     }
+// }
+
+interface Response {
+  status: 'success' | string
+  nip94_event?: {
+    tags?: string[][]
+  }
+}
+
+enum HandledErrorType {
+  'PUBKEY' = 'Failed to get public key.',
+  'SIGN' = 'Failed to sign the event.',
+  'AXIOS_REQ' = 'Image upload failed. Try another host from the dropdown.',
+  'AXIOS_RES' = 'Image upload failed. Reason: ',
+  'AXIOS_ERR' = 'Image upload failed.',
+  'NOSTR_CHECK_NO_SUCCESS' = 'Image upload was unsuccesfull.',
+  'NOSTR_CHECK_BAD_EVENT' = 'Image upload failed. Please try again.'
+}
+
+export class NostrCheckServer implements MediaOperations {
+  #media = 'api/v2/media'
+  #url: string
+
+  constructor(url: string) {
+    this.#url = url[url.length - 1] === '/' ? url : `${url}/`
+  }
+
+  post = async (file: File) => {
+    const url = `${this.#url}${this.#media}`
+    const auth = await this.auth()
+    try {
+      const response = await axios.postForm<Response>(
+        url,
+        {
+          uploadType: 'media',
+          file: file
+        },
+        {
+          headers: {
+            Authorization: 'Nostr ' + auth,
+            'Content-Type': 'multipart/form-data'
+          },
+          responseType: 'json'
+        }
+      )
+
+      if (response.data.status !== 'success') {
+        throw new BaseError(HandledErrorType.NOSTR_CHECK_NO_SUCCESS, {
+          context: { ...response.data }
+        })
+      }
+
+      if (
+        response.data &&
+        response.data.nip94_event &&
+        response.data.nip94_event.tags &&
+        response.data.nip94_event.tags.length
+      ) {
+        // Return first 'url' tag we find on the returned nip94 event
+        const imageUrl = response.data.nip94_event.tags.find(
+          (item) => item[0] === 'url'
+        )
+
+        if (imageUrl) return imageUrl[1]
+      }
+
+      throw new BaseError(HandledErrorType.NOSTR_CHECK_BAD_EVENT, {
+        context: { ...response.data }
+      })
+    } catch (error) {
+      // Handle axios errors
+      if (isAxiosError(error)) {
+        if (error.request) {
+          // The request was made but no response was received
+          throw new BaseError(HandledErrorType.AXIOS_REQ, {
+            cause: error
+          })
+        } else if (error.response) {
+          // The request was made and the server responded with a status code
+          // that falls out of the range of 2xx
+          // nostrcheck-server can return different results, including message or description
+          const data = error.response.data
+          let message = error.message
+          if (data) {
+            message = data?.message || data?.description || error.message
+          }
+          throw new BaseError(HandledErrorType.AXIOS_RES + message, {
+            cause: error
+          })
+        } else {
+          // Something happened in setting up the request that triggered an Error
+          throw new BaseError(HandledErrorType.AXIOS_ERR, {
+            cause: error
+          })
+        }
+      } else if (error instanceof BaseError) {
+        throw error
+      } else {
+        throw handleError(error)
+      }
+    }
+  }
+
+  auth = async () => {
+    try {
+      const url = `${this.#url}${this.#media}`
+
+      let hexPubkey: string
+      const userState = store.getState().user
+      if (userState.auth && userState.user?.pubkey) {
+        hexPubkey = userState.user.pubkey as string
+      } else {
+        hexPubkey = (await window.nostr?.getPublicKey()) as string
+      }
+
+      if (!hexPubkey) {
+        throw new BaseError(HandledErrorType.PUBKEY)
+      }
+
+      const unsignedEvent: NostrEvent = {
+        content: '',
+        created_at: now(),
+        kind: NDKKind.HttpAuth,
+        pubkey: hexPubkey,
+        tags: [
+          ['u', url],
+          ['method', 'POST']
+        ]
+      }
+
+      const signedEvent = await window.nostr?.signEvent(unsignedEvent)
+      return btoa(JSON.stringify(signedEvent))
+    } catch (error) {
+      if (error instanceof BaseError) {
+        throw error
+      }
+
+      throw new BaseError(HandledErrorType.SIGN, {
+        cause: handleError(error)
+      })
+    }
+  }
+}
diff --git a/src/controllers/image/route96.ts b/src/controllers/image/route96.ts
new file mode 100644
index 0000000..9451015
--- /dev/null
+++ b/src/controllers/image/route96.ts
@@ -0,0 +1,7 @@
+import { MediaOperations } from '.'
+
+export class route96 implements MediaOperations {
+  post = () => {
+    throw new Error('route96 post Not implemented.')
+  }
+}
diff --git a/src/controllers/index.ts b/src/controllers/index.ts
index 4e84779..9c81c4c 100644
--- a/src/controllers/index.ts
+++ b/src/controllers/index.ts
@@ -1 +1,2 @@
 export * from './zap'
+export * from './image'
diff --git a/src/types/errors.ts b/src/types/errors.ts
new file mode 100644
index 0000000..520d6f3
--- /dev/null
+++ b/src/types/errors.ts
@@ -0,0 +1,58 @@
+import { toast } from 'react-toastify'
+import { log, LogType } from 'utils'
+
+export type Jsonable =
+  | string
+  | number
+  | boolean
+  | null
+  | undefined
+  | readonly Jsonable[]
+  | { readonly [key: string]: Jsonable }
+  | { toJSON(): Jsonable }
+
+/**
+ * Handle errors
+ * Wraps the errors without message property and stringify to a message so we can use it later
+ * @param error
+ * @returns
+ */
+export function handleError(error: unknown): Error {
+  if (error instanceof Error) return error
+
+  // No message error, wrap it and stringify
+  let stringified = 'Unable to stringify the thrown value'
+  try {
+    stringified = JSON.stringify(error)
+  } catch (error) {
+    console.error(stringified, error)
+  }
+
+  return new Error(`Stringified Error: ${stringified}`)
+}
+
+export class BaseError extends Error {
+  public readonly context?: Jsonable
+
+  constructor(
+    message: string,
+    options: { cause?: Error; context?: Jsonable } = {}
+  ) {
+    const { cause, context } = options
+
+    super(message, { cause: cause })
+    this.name = this.constructor.name
+
+    this.context = context
+  }
+}
+
+export function errorFeedback(error: unknown) {
+  if (error instanceof Error) {
+    toast.error(error.message)
+    log(true, LogType.Error, error)
+  } else {
+    toast.error('Something went wrong.')
+    log(true, LogType.Error, error)
+  }
+}
diff --git a/src/types/index.ts b/src/types/index.ts
index 7b2f35c..7082027 100644
--- a/src/types/index.ts
+++ b/src/types/index.ts
@@ -6,3 +6,4 @@ export * from './zap'
 export * from './blog'
 export * from './category'
 export * from './popup'
+export * from './errors'