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 { useTranslation } from '@/i18n/useTranslation';
|
||||||
import { type CreateTextNoteParams } from '@/nostr/builder/createTextNote';
|
import { type CreateTextNoteParams } from '@/nostr/builder/createTextNote';
|
||||||
import { textNote } from '@/nostr/event';
|
import { textNote } from '@/nostr/event';
|
||||||
|
import Tags from '@/nostr/event/Tags';
|
||||||
import parseTextNote, { ParsedTextNote } from '@/nostr/parseTextNote';
|
import parseTextNote, { ParsedTextNote } from '@/nostr/parseTextNote';
|
||||||
import useCommands from '@/nostr/useCommands';
|
import useCommands from '@/nostr/useCommands';
|
||||||
import usePubkey from '@/nostr/usePubkey';
|
import usePubkey from '@/nostr/usePubkey';
|
||||||
import { uploadFiles, uploadNostrBuild } from '@/utils/imageUpload';
|
import { uploadNostrBuild } from '@/utils/imageUpload';
|
||||||
// import usePersistStatus from '@/hooks/usePersistStatus';
|
// import usePersistStatus from '@/hooks/usePersistStatus';
|
||||||
|
|
||||||
type NotePostFormProps = {
|
type NotePostFormProps = {
|
||||||
@@ -149,23 +150,47 @@ const NotePostForm: Component<NotePostFormProps> = (props) => {
|
|||||||
const uploadFilesMutation = createMutation(() => ({
|
const uploadFilesMutation = createMutation(() => ({
|
||||||
mutationKey: ['uploadFiles'] as const,
|
mutationKey: ['uploadFiles'] as const,
|
||||||
mutationFn: async (files: File[]) => {
|
mutationFn: async (files: File[]) => {
|
||||||
const uploadResults = await uploadFiles(uploadNostrBuild)(files);
|
const uploadResults = await uploadNostrBuild(files);
|
||||||
const failed: File[] = [];
|
const urls: string[] = [];
|
||||||
|
const failed: [File, string][] = [];
|
||||||
|
|
||||||
uploadResults.forEach((result, i) => {
|
uploadResults.forEach((result, i) => {
|
||||||
if (result.status === 'fulfilled') {
|
if (result.status === 'fulfilled') {
|
||||||
appendText(result.value.imageUrl);
|
const { status, nip94_event: nip94Event } = result.value;
|
||||||
resizeTextArea();
|
if ((status === 'success' || status === 'processing') && nip94Event != null) {
|
||||||
} else {
|
// TODO support delayed processing
|
||||||
failed.push(files[i]);
|
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) {
|
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 }));
|
window.alert(i18n.t('posting.failedToUploadFile', { filenames }));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
onError: (err) => {
|
||||||
|
console.error(err);
|
||||||
|
window.alert(err);
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const taggedPubkeysWithoutMe = createMemo(() => {
|
const taggedPubkeysWithoutMe = createMemo(() => {
|
||||||
@@ -299,8 +324,11 @@ const NotePostForm: Component<NotePostFormProps> = (props) => {
|
|||||||
if (uploadFilesMutation.isPending) return;
|
if (uploadFilesMutation.isPending) return;
|
||||||
// if (!ensureUploaderAgreement()) return;
|
// if (!ensureUploaderAgreement()) return;
|
||||||
|
|
||||||
const files = [...(ev.currentTarget.files ?? [])];
|
const {files} = ev.currentTarget;
|
||||||
uploadFilesMutation.mutate(files);
|
if (files == null || files.length === 0) return;
|
||||||
|
|
||||||
|
uploadFilesMutation.mutate([...files]);
|
||||||
|
|
||||||
// eslint-disable-next-line no-param-reassign
|
// eslint-disable-next-line no-param-reassign
|
||||||
ev.currentTarget.value = '';
|
ev.currentTarget.value = '';
|
||||||
};
|
};
|
||||||
@@ -309,17 +337,21 @@ const NotePostForm: Component<NotePostFormProps> = (props) => {
|
|||||||
ev.preventDefault();
|
ev.preventDefault();
|
||||||
if (uploadFilesMutation.isPending) return;
|
if (uploadFilesMutation.isPending) return;
|
||||||
// if (!ensureUploaderAgreement()) 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) => {
|
const handlePaste: JSX.EventHandler<HTMLTextAreaElement, ClipboardEvent> = (ev) => {
|
||||||
if (uploadFilesMutation.isPending) return;
|
if (uploadFilesMutation.isPending) return;
|
||||||
|
|
||||||
const items = [...(ev?.clipboardData?.items ?? [])];
|
const items = ev?.clipboardData?.items;
|
||||||
|
if (items == null || items.length === 0) return;
|
||||||
|
|
||||||
const files: File[] = [];
|
const files: File[] = [];
|
||||||
items.forEach((item) => {
|
Array.from(items).forEach((item) => {
|
||||||
if (item.kind === 'file') {
|
if (item.kind === 'file') {
|
||||||
ev.preventDefault();
|
ev.preventDefault();
|
||||||
const file = item.getAsFile();
|
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).
|
// The original code was published under the public domain license (CC0-1.0).
|
||||||
// https://gist.github.com/syusui-s/cd5482ddfc83792b54a756759acbda55
|
// 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 = {
|
type NostrAPI = {
|
||||||
/** returns a public key as hex */
|
/** returns a public key as hex */
|
||||||
getPublicKey(): Promise<string>;
|
getPublicKey(): Promise<string>;
|
||||||
/** takes an event object, adds `id`, `pubkey` and `sig` and returns it */
|
/** takes an event object, adds `id`, `pubkey` and `sig` and returns it */
|
||||||
signEvent(event: UnsignedEvent): Promise<NostrEvent>;
|
signEvent(event: EventTemplate): Promise<NostrEvent>;
|
||||||
|
|
||||||
// Optional
|
// Optional
|
||||||
|
|
||||||
|
|||||||
@@ -1,74 +1,84 @@
|
|||||||
export type UploadResult = {
|
import { readServerConfig, type FileUploadResponse } from 'nostr-tools/nip96';
|
||||||
imageUrl: string;
|
import { getToken } from 'nostr-tools/nip98';
|
||||||
};
|
import { type EventTemplate } from 'nostr-tools/pure';
|
||||||
|
|
||||||
export type Uploader = {
|
export type ServerDefinition = {
|
||||||
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
tos: string;
|
upload: (files: File[]) => Promise<PromiseSettledResult<FileUploadResponse>[]>;
|
||||||
upload: (file: File) => Promise<UploadResult>;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export type NostrBuildResult = {
|
export type UploadFileStorageProps = {
|
||||||
status: 'success' | 'error';
|
files: File[];
|
||||||
message: string;
|
serverUrl: 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 const uploadNostrBuild = async (blob: Blob): Promise<UploadResult> => {
|
export type UploadFileProps = {
|
||||||
const form = new FormData();
|
file: File;
|
||||||
form.set('file', blob);
|
authorizationHeader?: string;
|
||||||
|
media_type?: 'avatar' | 'banner';
|
||||||
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 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: {
|
nostrBuild: {
|
||||||
|
id: 'nostrBuild',
|
||||||
name: 'nostr.build',
|
name: 'nostr.build',
|
||||||
tos: 'https://nostr.build/tos/',
|
|
||||||
upload: uploadNostrBuild,
|
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