From c8ca583dfcaf4ab349c3d4512213613efb2d5cb3 Mon Sep 17 00:00:00 2001 From: Shusui MOYATANI Date: Sat, 18 Mar 2023 11:16:58 +0900 Subject: [PATCH] feat: image upload --- src/components/NotePostForm.tsx | 81 +++++++++++++++++++++++++++++++-- src/nostr/useSubscription.ts | 14 +++++- src/utils/imageUpload.ts | 35 +++++++++++++- 3 files changed, 122 insertions(+), 8 deletions(-) diff --git a/src/components/NotePostForm.tsx b/src/components/NotePostForm.tsx index 7b01468..9750711 100644 --- a/src/components/NotePostForm.tsx +++ b/src/components/NotePostForm.tsx @@ -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 = (props) => { let textAreaRef: HTMLTextAreaElement | undefined; + let fileInputRef: HTMLInputElement | undefined; const [text, setText] = createSignal(''); + const [isUploading, setIsUploading] = createSignal(false); + const [isDragging, setIsDragging] = createSignal(false); + const clearText = () => setText(''); const { config } = useConfig(); @@ -68,6 +75,24 @@ const NotePostForm: Component = (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 = createMemo( () => replyTo()?.mentionedPubkeysWithoutAuthor() ?? [], ); @@ -106,9 +131,30 @@ const NotePostForm: Component = (props) => { } }; - const submitDisabled = createMemo( - () => text().trim().length === 0 || publishTextNoteMutation.isLoading, - ); + const handleChangeFile: JSX.EventHandler = (ev) => { + ev.preventDefault(); + const files = [...(ev.currentTarget.files ?? [])]; + uploadFilesMutation.mutate(files); + ev.currentTarget.value = ''; + }; + + const handleDrop: JSX.EventHandler = (ev) => { + ev.preventDefault(); + const files = [...(ev?.dataTransfer?.files ?? [])]; + uploadFilesMutation.mutate(files); + }; + + const handleDragOver: JSX.EventHandler = (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 = (props) => { placeholder={placeholder(mode())} onInput={(ev) => setText(ev.currentTarget.value)} onKeyDown={handleKeyDown} + onDragOver={handleDragOver} + onDrop={handleDrop} value={text()} /> -
+
+
+
); diff --git a/src/nostr/useSubscription.ts b/src/nostr/useSubscription.ts index fb6e225..8af333a 100644 --- a/src/nostr/useSubscription.ts +++ b/src/nostr/useSubscription.ts @@ -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 { - // いったん50件だけ保持 - setEvents((prevEvents) => sortEvents([event, ...prevEvents].slice(0, 50))); + setEvents((current) => { + // いったん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; + }); } }); diff --git a/src/utils/imageUpload.ts b/src/utils/imageUpload.ts index 9fd3e5d..daefe62 100644 --- a/src/utils/imageUpload.ts +++ b/src/utils/imageUpload.ts @@ -9,7 +9,33 @@ const toHexString = (buff: ArrayBuffer): string => { return arr.join(); }; -const upload = async (blob: Blob): Promise => { +export type UploadResult = { + imageUrl: string; +}; + +export const uploadNostrBuild = async (blob: Blob): Promise => { + 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 => { 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 => { 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 => { return res.json(); }; -export default upload; +export const uploadFiles = + (uploadFn: (file: Blob) => Promise) => + (files: File[]): Promise>[]> => { + return Promise.allSettled(files.map((file) => uploadFn(file))); + };