mirror of
https://github.com/aljazceru/rabbit.git
synced 2025-12-18 14:34:25 +01:00
feat: image upload
This commit is contained in:
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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)));
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user