feat: image upload

This commit is contained in:
Shusui MOYATANI
2023-03-18 11:16:58 +09:00
parent f1cd3f85aa
commit c8ca583dfc
3 changed files with 122 additions and 8 deletions

View File

@@ -13,6 +13,7 @@ import { Event as NostrEvent } from 'nostr-tools';
import uniq from 'lodash/uniq';
import PaperAirplane from 'heroicons/24/solid/paper-airplane.svg';
import Photo from 'heroicons/24/outline/photo.svg';
import XMark from 'heroicons/24/outline/x-mark.svg';
import UserNameDisplay from '@/components/UserDisplayName';
@@ -24,6 +25,8 @@ import useCommands from '@/nostr/useCommands';
import usePubkey from '@/nostr/usePubkey';
import { useHandleCommand } from '@/hooks/useCommandBus';
import { uploadNostrBuild, uploadFiles } from '@/utils/imageUpload';
type NotePostFormProps = {
replyTo?: NostrEvent;
mode?: 'normal' | 'reply';
@@ -44,8 +47,12 @@ const placeholder = (mode: NotePostFormProps['mode']) => {
const NotePostForm: Component<NotePostFormProps> = (props) => {
let textAreaRef: HTMLTextAreaElement | undefined;
let fileInputRef: HTMLInputElement | undefined;
const [text, setText] = createSignal<string>('');
const [isUploading, setIsUploading] = createSignal(false);
const [isDragging, setIsDragging] = createSignal(false);
const clearText = () => setText('');
const { config } = useConfig();
@@ -68,6 +75,24 @@ const NotePostForm: Component<NotePostFormProps> = (props) => {
},
});
const uploadFilesMutation = createMutation({
mutationKey: ['uploadFiles'],
mutationFn: (files: File[]) => {
return uploadFiles(uploadNostrBuild)(files)
.then((uploadResults) => {
uploadResults.forEach((result) => {
if (result.status === 'fulfilled') {
console.log('succeeded to upload', result);
setText((current) => `${current} ${result.value.imageUrl}`);
} else {
console.error('failed to upload', result);
}
});
})
.catch((err) => console.error(err));
},
});
const mentionedPubkeys: Accessor<string[]> = createMemo(
() => replyTo()?.mentionedPubkeysWithoutAuthor() ?? [],
);
@@ -106,9 +131,30 @@ const NotePostForm: Component<NotePostFormProps> = (props) => {
}
};
const submitDisabled = createMemo(
() => text().trim().length === 0 || publishTextNoteMutation.isLoading,
);
const handleChangeFile: JSX.EventHandler<HTMLInputElement, Event> = (ev) => {
ev.preventDefault();
const files = [...(ev.currentTarget.files ?? [])];
uploadFilesMutation.mutate(files);
ev.currentTarget.value = '';
};
const handleDrop: JSX.EventHandler<HTMLTextAreaElement, DragEvent> = (ev) => {
ev.preventDefault();
const files = [...(ev?.dataTransfer?.files ?? [])];
uploadFilesMutation.mutate(files);
};
const handleDragOver: JSX.EventHandler<HTMLTextAreaElement, DragEvent> = (ev) => {
ev.preventDefault();
setIsDragging(true);
};
const submitDisabled = () =>
text().trim().length === 0 ||
publishTextNoteMutation.isLoading ||
uploadFilesMutation.isLoading;
const fileUploadDisabled = () => uploadFilesMutation.isLoading;
onMount(() => {
setTimeout(() => {
@@ -142,9 +188,11 @@ const NotePostForm: Component<NotePostFormProps> = (props) => {
placeholder={placeholder(mode())}
onInput={(ev) => setText(ev.currentTarget.value)}
onKeyDown={handleKeyDown}
onDragOver={handleDragOver}
onDrop={handleDrop}
value={text()}
/>
<div class="flex items-end justify-end">
<div class="flex items-end justify-end gap-1">
<Show when={mode() === 'reply'}>
<div class="flex-1">
<button class="h-5 w-5 text-stone-500" onClick={() => props.onClose()}>
@@ -152,6 +200,22 @@ const NotePostForm: Component<NotePostFormProps> = (props) => {
</button>
</div>
</Show>
<button
class="rounded bg-primary p-2 font-bold text-white"
classList={{
'bg-primary-disabled': fileUploadDisabled(),
'bg-primary': !fileUploadDisabled(),
'h-8': mode() === 'normal',
'w-8': mode() === 'normal',
'h-7': mode() === 'reply',
'w-7': mode() === 'reply',
}}
type="button"
disabled={fileUploadDisabled()}
onClick={() => fileInputRef?.click()}
>
<Photo />
</button>
<button
class="rounded bg-primary p-2 font-bold text-white"
classList={{
@@ -168,6 +232,15 @@ const NotePostForm: Component<NotePostFormProps> = (props) => {
<PaperAirplane />
</button>
</div>
<input
ref={fileInputRef}
class="rounded bg-primary"
type="file"
hidden
name="image"
accept="image/jpeg,image/png,image/gif,image/webp"
onChange={handleChangeFile}
/>
</form>
</div>
);

View File

@@ -1,5 +1,6 @@
import { createSignal, createEffect, onCleanup } from 'solid-js';
import type { Event as NostrEvent, Filter, SubscriptionOptions } from 'nostr-tools';
import uniqBy from 'lodash/uniqBy';
import usePool from '@/nostr/usePool';
export type UseSubscriptionProps = {
@@ -44,8 +45,17 @@ const useSubscription = (propsProvider: () => UseSubscriptionProps | null) => {
pushed = true;
storedEvents.push(event);
} else {
setEvents((current) => {
// いったん50件だけ保持
setEvents((prevEvents) => sortEvents([event, ...prevEvents].slice(0, 50)));
const sorted = sortEvents([event, ...current].slice(0, 50));
// FIXME なぜか重複して取得される問題があるが一旦uniqByで対処
// https://github.com/syusui-s/rabbit/issues/5
const deduped = uniqBy(sorted, (e) => e.id);
if (deduped.length !== sorted.length) {
console.warn('duplicated event', event);
}
return deduped;
});
}
});

View File

@@ -9,7 +9,33 @@ const toHexString = (buff: ArrayBuffer): string => {
return arr.join();
};
const upload = async (blob: Blob): Promise<object> => {
export type UploadResult = {
imageUrl: string;
};
export const uploadNostrBuild = async (blob: Blob): Promise<UploadResult> => {
const form = new FormData();
form.set('fileToUpload', blob);
form.set('img_url', '');
form.set('submit', 'Upload');
const res = await fetch('https://nostr.build/api/upload/uploadapi.php', {
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 imageUrl = (await res.json()) as string;
return { imageUrl };
};
export const uploadVoidCat = async (blob: Blob): Promise<any> => {
const data = await blob.arrayBuffer();
const digestBuffer = await window.crypto.subtle.digest('SHA-256', data);
const digest = toHexString(digestBuffer);
@@ -17,6 +43,7 @@ const upload = async (blob: Blob): Promise<object> => {
const res = await fetch('https://void.cat/upload', {
method: 'POST',
headers: {
Accept: 'application/json',
'V-Content-Type': blob.type,
'V-Full-Digest': digest,
},
@@ -29,4 +56,8 @@ const upload = async (blob: Blob): Promise<object> => {
return res.json();
};
export default upload;
export const uploadFiles =
<T>(uploadFn: (file: Blob) => Promise<T>) =>
(files: File[]): Promise<PromiseSettledResult<Awaited<T>>[]> => {
return Promise.allSettled(files.map((file) => uploadFn(file)));
};