mirror of
https://github.com/aljazceru/rabbit.git
synced 2025-12-17 05:54:19 +01:00
feat: upload nostr.build with NIP-96
This commit is contained in:
@@ -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<NotePostFormProps> = (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();
|
||||
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.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<NotePostFormProps> = (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<NotePostFormProps> = (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<HTMLTextAreaElement, ClipboardEvent> = (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();
|
||||
|
||||
4
src/types/nostr.d.ts
vendored
4
src/types/nostr.d.ts
vendored
@@ -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<string>;
|
||||
/** takes an event object, adds `id`, `pubkey` and `sig` and returns it */
|
||||
signEvent(event: UnsignedEvent): Promise<NostrEvent>;
|
||||
signEvent(event: EventTemplate): Promise<NostrEvent>;
|
||||
|
||||
// Optional
|
||||
|
||||
|
||||
@@ -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<UploadResult>;
|
||||
upload: (files: File[]) => Promise<PromiseSettledResult<FileUploadResponse>[]>;
|
||||
};
|
||||
|
||||
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<string, string>;
|
||||
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<UploadResult> => {
|
||||
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<string> => {
|
||||
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<FileUploadResponse> => {
|
||||
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<PromiseSettledResult<FileUploadResponse>[]> => {
|
||||
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<string, ServerDefinition> = {
|
||||
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 =
|
||||
<T>(uploadFn: (file: Blob) => Promise<T>) =>
|
||||
(files: File[]): Promise<PromiseSettledResult<Awaited<T>>[]> =>
|
||||
Promise.allSettled(files.map((file) => uploadFn(file)));
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user