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 uniq from 'lodash/uniq';
|
||||||
|
|
||||||
import PaperAirplane from 'heroicons/24/solid/paper-airplane.svg';
|
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 XMark from 'heroicons/24/outline/x-mark.svg';
|
||||||
|
|
||||||
import UserNameDisplay from '@/components/UserDisplayName';
|
import UserNameDisplay from '@/components/UserDisplayName';
|
||||||
@@ -24,6 +25,8 @@ import useCommands from '@/nostr/useCommands';
|
|||||||
import usePubkey from '@/nostr/usePubkey';
|
import usePubkey from '@/nostr/usePubkey';
|
||||||
import { useHandleCommand } from '@/hooks/useCommandBus';
|
import { useHandleCommand } from '@/hooks/useCommandBus';
|
||||||
|
|
||||||
|
import { uploadNostrBuild, uploadFiles } from '@/utils/imageUpload';
|
||||||
|
|
||||||
type NotePostFormProps = {
|
type NotePostFormProps = {
|
||||||
replyTo?: NostrEvent;
|
replyTo?: NostrEvent;
|
||||||
mode?: 'normal' | 'reply';
|
mode?: 'normal' | 'reply';
|
||||||
@@ -44,8 +47,12 @@ const placeholder = (mode: NotePostFormProps['mode']) => {
|
|||||||
|
|
||||||
const NotePostForm: Component<NotePostFormProps> = (props) => {
|
const NotePostForm: Component<NotePostFormProps> = (props) => {
|
||||||
let textAreaRef: HTMLTextAreaElement | undefined;
|
let textAreaRef: HTMLTextAreaElement | undefined;
|
||||||
|
let fileInputRef: HTMLInputElement | undefined;
|
||||||
|
|
||||||
const [text, setText] = createSignal<string>('');
|
const [text, setText] = createSignal<string>('');
|
||||||
|
const [isUploading, setIsUploading] = createSignal(false);
|
||||||
|
const [isDragging, setIsDragging] = createSignal(false);
|
||||||
|
|
||||||
const clearText = () => setText('');
|
const clearText = () => setText('');
|
||||||
|
|
||||||
const { config } = useConfig();
|
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(
|
const mentionedPubkeys: Accessor<string[]> = createMemo(
|
||||||
() => replyTo()?.mentionedPubkeysWithoutAuthor() ?? [],
|
() => replyTo()?.mentionedPubkeysWithoutAuthor() ?? [],
|
||||||
);
|
);
|
||||||
@@ -106,9 +131,30 @@ const NotePostForm: Component<NotePostFormProps> = (props) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const submitDisabled = createMemo(
|
const handleChangeFile: JSX.EventHandler<HTMLInputElement, Event> = (ev) => {
|
||||||
() => text().trim().length === 0 || publishTextNoteMutation.isLoading,
|
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(() => {
|
onMount(() => {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
@@ -142,9 +188,11 @@ const NotePostForm: Component<NotePostFormProps> = (props) => {
|
|||||||
placeholder={placeholder(mode())}
|
placeholder={placeholder(mode())}
|
||||||
onInput={(ev) => setText(ev.currentTarget.value)}
|
onInput={(ev) => setText(ev.currentTarget.value)}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
|
onDragOver={handleDragOver}
|
||||||
|
onDrop={handleDrop}
|
||||||
value={text()}
|
value={text()}
|
||||||
/>
|
/>
|
||||||
<div class="flex items-end justify-end">
|
<div class="flex items-end justify-end gap-1">
|
||||||
<Show when={mode() === 'reply'}>
|
<Show when={mode() === 'reply'}>
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<button class="h-5 w-5 text-stone-500" onClick={() => props.onClose()}>
|
<button class="h-5 w-5 text-stone-500" onClick={() => props.onClose()}>
|
||||||
@@ -152,6 +200,22 @@ const NotePostForm: Component<NotePostFormProps> = (props) => {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</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
|
<button
|
||||||
class="rounded bg-primary p-2 font-bold text-white"
|
class="rounded bg-primary p-2 font-bold text-white"
|
||||||
classList={{
|
classList={{
|
||||||
@@ -168,6 +232,15 @@ const NotePostForm: Component<NotePostFormProps> = (props) => {
|
|||||||
<PaperAirplane />
|
<PaperAirplane />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { createSignal, createEffect, onCleanup } from 'solid-js';
|
import { createSignal, createEffect, onCleanup } from 'solid-js';
|
||||||
import type { Event as NostrEvent, Filter, SubscriptionOptions } from 'nostr-tools';
|
import type { Event as NostrEvent, Filter, SubscriptionOptions } from 'nostr-tools';
|
||||||
|
import uniqBy from 'lodash/uniqBy';
|
||||||
import usePool from '@/nostr/usePool';
|
import usePool from '@/nostr/usePool';
|
||||||
|
|
||||||
export type UseSubscriptionProps = {
|
export type UseSubscriptionProps = {
|
||||||
@@ -44,8 +45,17 @@ const useSubscription = (propsProvider: () => UseSubscriptionProps | null) => {
|
|||||||
pushed = true;
|
pushed = true;
|
||||||
storedEvents.push(event);
|
storedEvents.push(event);
|
||||||
} else {
|
} else {
|
||||||
// いったん50件だけ保持
|
setEvents((current) => {
|
||||||
setEvents((prevEvents) => sortEvents([event, ...prevEvents].slice(0, 50)));
|
// いったん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();
|
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 data = await blob.arrayBuffer();
|
||||||
const digestBuffer = await window.crypto.subtle.digest('SHA-256', data);
|
const digestBuffer = await window.crypto.subtle.digest('SHA-256', data);
|
||||||
const digest = toHexString(digestBuffer);
|
const digest = toHexString(digestBuffer);
|
||||||
@@ -17,6 +43,7 @@ const upload = async (blob: Blob): Promise<object> => {
|
|||||||
const res = await fetch('https://void.cat/upload', {
|
const res = await fetch('https://void.cat/upload', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
|
Accept: 'application/json',
|
||||||
'V-Content-Type': blob.type,
|
'V-Content-Type': blob.type,
|
||||||
'V-Full-Digest': digest,
|
'V-Full-Digest': digest,
|
||||||
},
|
},
|
||||||
@@ -29,4 +56,8 @@ const upload = async (blob: Blob): Promise<object> => {
|
|||||||
return res.json();
|
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