diff --git a/src/components/NotePostForm.tsx b/src/components/NotePostForm.tsx index 2794a63..7b6387d 100644 --- a/src/components/NotePostForm.tsx +++ b/src/components/NotePostForm.tsx @@ -16,10 +16,11 @@ import useEmojiComplete from '@/hooks/useEmojiComplete'; import { useTranslation } from '@/i18n/useTranslation'; import { type CreateTextNoteParams } from '@/nostr/builder/createTextNote'; import { textNote } from '@/nostr/event'; +import Tags from '@/nostr/event/Tags'; import parseTextNote, { ParsedTextNote } from '@/nostr/parseTextNote'; import useCommands from '@/nostr/useCommands'; import usePubkey from '@/nostr/usePubkey'; -import { uploadFiles, uploadNostrBuild } from '@/utils/imageUpload'; +import { uploadNostrBuild } from '@/utils/imageUpload'; // import usePersistStatus from '@/hooks/usePersistStatus'; type NotePostFormProps = { @@ -149,23 +150,47 @@ const NotePostForm: Component = (props) => { const uploadFilesMutation = createMutation(() => ({ mutationKey: ['uploadFiles'] as const, mutationFn: async (files: File[]) => { - const uploadResults = await uploadFiles(uploadNostrBuild)(files); - const failed: File[] = []; + const uploadResults = await uploadNostrBuild(files); + const urls: string[] = []; + const failed: [File, string][] = []; uploadResults.forEach((result, i) => { if (result.status === 'fulfilled') { - appendText(result.value.imageUrl); - resizeTextArea(); - } else { - failed.push(files[i]); - } + const { status, nip94_event: nip94Event } = result.value; + if ((status === 'success' || status === 'processing') && nip94Event != null) { + // TODO support delayed processing + const tags = new Tags(nip94Event.tags); + const urlTag = tags.findFirstTagByName('url'); + + if (urlTag == null || urlTag.length < 2) { + failed.push([files[i], 'url not found']); + return; + } + const url = urlTag[1]; + urls.push(url); + } else if (result.value.status === 'error') { + failed.push([files[i], result.value.message]); + } + } else if (result.reason instanceof Error) { + failed.push([files[i], result.reason.message]); + } else { + failed.push([files[i], 'failed']); + } }); + if (urls.length > 0) { + appendText(urls.join(' ')); + resizeTextArea(); + } if (failed.length > 0) { - const filenames = failed.map((f) => f.name).join(', '); + const filenames = failed.map(([f, reason]) => `${f.name}: ${reason}`).join('\n'); window.alert(i18n.t('posting.failedToUploadFile', { filenames })); } }, + onError: (err) => { + console.error(err); + window.alert(err); + }, })); const taggedPubkeysWithoutMe = createMemo(() => { @@ -299,8 +324,11 @@ const NotePostForm: Component = (props) => { if (uploadFilesMutation.isPending) return; // if (!ensureUploaderAgreement()) return; - const files = [...(ev.currentTarget.files ?? [])]; - uploadFilesMutation.mutate(files); + const {files} = ev.currentTarget; + if (files == null || files.length === 0) return; + + uploadFilesMutation.mutate([...files]); + // eslint-disable-next-line no-param-reassign ev.currentTarget.value = ''; }; @@ -309,17 +337,21 @@ const NotePostForm: Component = (props) => { ev.preventDefault(); if (uploadFilesMutation.isPending) return; // if (!ensureUploaderAgreement()) return; - const files = [...(ev?.dataTransfer?.files ?? [])]; - uploadFilesMutation.mutate(files); + + const files = ev?.dataTransfer?.files; + if (files == null || files.length === 0) return; + + uploadFilesMutation.mutate([...files]); }; const handlePaste: JSX.EventHandler = (ev) => { if (uploadFilesMutation.isPending) return; - const items = [...(ev?.clipboardData?.items ?? [])]; + const items = ev?.clipboardData?.items; + if (items == null || items.length === 0) return; const files: File[] = []; - items.forEach((item) => { + Array.from(items).forEach((item) => { if (item.kind === 'file') { ev.preventDefault(); const file = item.getAsFile(); diff --git a/src/types/nostr.d.ts b/src/types/nostr.d.ts index cbd7752..6e9678e 100644 --- a/src/types/nostr.d.ts +++ b/src/types/nostr.d.ts @@ -1,12 +1,12 @@ // The original code was published under the public domain license (CC0-1.0). // https://gist.github.com/syusui-s/cd5482ddfc83792b54a756759acbda55 -import { type UnsignedEvent, type Event as NostrEvent } from 'nostr-tools/pure'; +import { type EventTemplate, type Event as NostrEvent } from 'nostr-tools/pure'; type NostrAPI = { /** returns a public key as hex */ getPublicKey(): Promise; /** takes an event object, adds `id`, `pubkey` and `sig` and returns it */ - signEvent(event: UnsignedEvent): Promise; + signEvent(event: EventTemplate): Promise; // Optional diff --git a/src/utils/imageUpload.ts b/src/utils/imageUpload.ts index 8673e68..b99f127 100644 --- a/src/utils/imageUpload.ts +++ b/src/utils/imageUpload.ts @@ -1,74 +1,84 @@ -export type UploadResult = { - imageUrl: string; -}; +import { readServerConfig, type FileUploadResponse } from 'nostr-tools/nip96'; +import { getToken } from 'nostr-tools/nip98'; +import { type EventTemplate } from 'nostr-tools/pure'; -export type Uploader = { +export type ServerDefinition = { + id: string; name: string; - tos: string; - upload: (file: File) => Promise; + upload: (files: File[]) => Promise[]>; }; -export type NostrBuildResult = { - status: 'success' | 'error'; - message: string; - data: { - input_name: string; - name: string; - url: string; - thumbnail?: string; - blurhash?: string; - sha256: string; - type: 'image' | 'video' | 'profile' | 'other'; - mime: string; - size: number; - metadata?: Record; - dimensions?: { - width: number; - height: number; - }; - responsive?: { - '240p': string; - '360p': string; - '480p': string; - '720p': string; - '1080p': string; - }; - }[]; +export type UploadFileStorageProps = { + files: File[]; + serverUrl: string; }; -export const uploadNostrBuild = async (blob: Blob): Promise => { - const form = new FormData(); - form.set('file', blob); - - const res = await fetch('https://nostr.build/api/v2/upload/files', { - method: 'POST', - headers: { - Accept: 'application/json', - }, - mode: 'cors', - body: form, - }); - - if (!res.ok) throw new Error('failed to post image: status code was not 2xx'); - - const result = (await res.json()) as NostrBuildResult; - - if (result.status !== 'success') throw new Error(`failed to post image: ${result.message}`); - - return { imageUrl: result.data[0].url }; +export type UploadFileProps = { + file: File; + authorizationHeader?: string; + media_type?: 'avatar' | 'banner'; }; -export const uploaders = { +export const getAuthorizationHeader = (uploadApiUrl: string): Promise => { + const windowNostr = window.nostr; + if (windowNostr == null) throw new Error('NIP-07 implementation not found'); + + const method = 'POST'; + const signEvent = (ev: EventTemplate) => windowNostr.signEvent(ev); + const includeAuthorizationScheme = true; + + return getToken(uploadApiUrl, method, signEvent, includeAuthorizationScheme); +}; + +export const uploadFile = async ( + uploadApiUrl: string, + props: UploadFileProps, +): Promise => { + const body = new FormData(); + body.set('file', props.file); + body.set('content_type', props.file.type); + body.set('size', props.file.size.toString(10)); + + const headers = new Headers(); + if (props.authorizationHeader != null) { + headers.set('Authorization', props.authorizationHeader); + } + + const response = await fetch(uploadApiUrl, { method: 'POST', headers, body }); + + // TODO validate event + const json = (await response.json()) as FileUploadResponse; + + if (!response.ok) { + throw new Error(`failed to upload: ${response.status} ${json.message}`); + } + + return json; +}; + +export const uploadFileStorage = async ( + props: UploadFileStorageProps, +): Promise[]> => { + const serverConfig = await readServerConfig(props.serverUrl); + + if (serverConfig.api_url.length === 0 || serverConfig.delegated_to_url != null) { + throw new Error('delegated_to_url is not supported'); + } + const uploadApiUrl = serverConfig.api_url; + const authorizationHeader = await getAuthorizationHeader(uploadApiUrl); + + const promises = Array.from(props.files).map(async (file) => uploadFile(uploadApiUrl, { authorizationHeader, file })); + + return Promise.allSettled(promises); +}; + +export const uploadNostrBuild = (files: File[]) => + uploadFileStorage({ files, serverUrl: 'https://nostr.build' }); + +export const servers: Record = { nostrBuild: { + id: 'nostrBuild', name: 'nostr.build', - tos: 'https://nostr.build/tos/', upload: uploadNostrBuild, - } satisfies Uploader, -} as const; - -export type UploaderIds = keyof typeof uploaders; - -export const uploadFiles = - (uploadFn: (file: Blob) => Promise) => - (files: File[]): Promise>[]> => - Promise.allSettled(files.map((file) => uploadFn(file))); + }, +};