update composer with image upload
This commit is contained in:
@@ -93,7 +93,7 @@ export function UnlockScreen() {
|
||||
<input
|
||||
{...register('password', { required: true })}
|
||||
type={passwordInput}
|
||||
className="relative w-full rounded-lg bg-zinc-800 py-3 pl-3.5 pr-11 text-zinc-100 !outline-none placeholder:text-zinc-400"
|
||||
className="relative w-full rounded-lg bg-zinc-800 py-3 text-center text-zinc-100 !outline-none placeholder:text-zinc-400"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -15,15 +15,19 @@ button {
|
||||
}
|
||||
|
||||
.markdown {
|
||||
@apply prose prose-zinc max-w-none select-text break-words dark:prose-invert prose-p:mb-2 prose-p:mt-0 prose-p:last:mb-0 prose-a:break-all prose-a:font-normal prose-a:leading-tight prose-a:text-fuchsia-400 hover:prose-a:text-fuchsia-500 prose-blockquote:m-0 prose-ol:m-0 prose-ol:mb-1 prose-ul:mb-1 prose-li:leading-tight prose-hr:mx-0 prose-hr:my-2;
|
||||
@apply prose prose-zinc max-w-none select-text break-words dark:prose-invert prose-p:mb-2 prose-p:mt-0 prose-p:last:mb-0 prose-a:break-all prose-a:font-normal prose-a:leading-tight prose-a:text-fuchsia-400 hover:prose-a:text-fuchsia-500 prose-blockquote:m-0 prose-ol:m-0 prose-ol:mb-1 prose-ul:mb-1 prose-li:leading-tight prose-img:mt-1.5 prose-img:mb-1 prose-hr:mx-0 prose-hr:my-2;
|
||||
}
|
||||
|
||||
.ProseMirror p.is-editor-empty:first-child::before {
|
||||
.ProseMirror p.is-empty::before {
|
||||
@apply text-zinc-400;
|
||||
content: attr(data-placeholder);
|
||||
float: left;
|
||||
height: 0;
|
||||
pointer-events: none;
|
||||
@apply text-zinc-400;
|
||||
}
|
||||
|
||||
.ProseMirror img.ProseMirror-selectednode {
|
||||
@apply outline-fuchsia-500;
|
||||
}
|
||||
|
||||
/* For Webkit-based browsers (Chrome, Safari and Opera) */
|
||||
|
||||
@@ -459,6 +459,7 @@ export async function getAllMetadata() {
|
||||
return {
|
||||
pubkey: el.pubkey,
|
||||
ident: profile.name || profile.display_name || profile.username,
|
||||
picture: profile.picture || profile.image,
|
||||
};
|
||||
});
|
||||
return users;
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
|
||||
import { NDKProvider } from '@libs/ndk/provider';
|
||||
@@ -25,6 +24,5 @@ root.render(
|
||||
<NDKProvider>
|
||||
<App />
|
||||
</NDKProvider>
|
||||
<ReactQueryDevtools initialIsOpen={false} position="top-right" />
|
||||
</QueryClientProvider>
|
||||
);
|
||||
|
||||
@@ -1,54 +1,80 @@
|
||||
import { TauriEvent } from '@tauri-apps/api/event';
|
||||
import { getCurrent } from '@tauri-apps/api/window';
|
||||
import Image from '@tiptap/extension-image';
|
||||
import Mention from '@tiptap/extension-mention';
|
||||
import Placeholder from '@tiptap/extension-placeholder';
|
||||
import { EditorContent, useEditor } from '@tiptap/react';
|
||||
import StarterKit from '@tiptap/starter-kit';
|
||||
import { convert } from 'html-to-text';
|
||||
import { nip19 } from 'nostr-tools';
|
||||
import { useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
import { Button } from '@shared/button';
|
||||
import { Suggestion } from '@shared/composer';
|
||||
import { CancelIcon, LoaderIcon } from '@shared/icons';
|
||||
import { CancelIcon, LoaderIcon, PlusCircleIcon } from '@shared/icons';
|
||||
import { MentionNote } from '@shared/notes';
|
||||
|
||||
import { useComposer } from '@stores/composer';
|
||||
import { FULL_RELAYS } from '@stores/constants';
|
||||
|
||||
import { usePublish } from '@utils/hooks/usePublish';
|
||||
import { useImageUploader } from '@utils/hooks/useUploader';
|
||||
import { sendNativeNotification } from '@utils/notification';
|
||||
|
||||
export function Composer() {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [reply, clearReply, toggle] = useComposer((state) => [
|
||||
const [status, setStatus] = useState<null | 'loading' | 'done'>(null);
|
||||
const [reply, clearReply, toggleModal] = useComposer((state) => [
|
||||
state.reply,
|
||||
state.clearReply,
|
||||
state.toggleModal,
|
||||
]);
|
||||
|
||||
const publish = usePublish();
|
||||
const editor = useEditor({
|
||||
extensions: [
|
||||
StarterKit,
|
||||
Placeholder.configure({ placeholder: "What's on your mind?" }),
|
||||
StarterKit.configure({
|
||||
dropcursor: {
|
||||
color: '#fff',
|
||||
},
|
||||
}),
|
||||
Placeholder.configure({ placeholder: 'Type something...' }),
|
||||
Mention.configure({
|
||||
suggestion: Suggestion,
|
||||
renderLabel({ node }) {
|
||||
return `nostr:${nip19.npubEncode(node.attrs.id.pubkey)} `;
|
||||
},
|
||||
}),
|
||||
Image.configure({
|
||||
HTMLAttributes: {
|
||||
class:
|
||||
'rounded-lg w-2/3 h-auto border border-zinc-800 outline outline-2 outline-offset-0 outline-zinc-700 ml-1',
|
||||
},
|
||||
}),
|
||||
],
|
||||
content: '',
|
||||
editorProps: {
|
||||
attributes: {
|
||||
class: twMerge(
|
||||
'markdown break-all max-h-[500px] overflow-y-auto outline-none',
|
||||
`${reply.id ? '!min-h-42' : '!min-h-[86px]'}`
|
||||
'scrollbar-hide markdown break-all max-h-[500px] overflow-y-auto outline-none pr-2',
|
||||
`${reply.id ? '!min-h-42' : '!min-h-[100px]'}`
|
||||
),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const upload = useImageUploader();
|
||||
const publish = usePublish();
|
||||
|
||||
const uploadImage = async (file?: string) => {
|
||||
const image = await upload(file);
|
||||
if (image.url) {
|
||||
editor.commands.setImage({ src: image.url });
|
||||
editor.commands.createParagraphNear();
|
||||
}
|
||||
};
|
||||
|
||||
const submit = async () => {
|
||||
setLoading(true);
|
||||
setStatus('loading');
|
||||
try {
|
||||
let tags: string[][] = [];
|
||||
|
||||
@@ -65,33 +91,55 @@ export function Composer() {
|
||||
['p', reply.pubkey],
|
||||
];
|
||||
}
|
||||
} else {
|
||||
tags = [];
|
||||
}
|
||||
|
||||
// get plaintext content
|
||||
const serializedContent = editor.getText();
|
||||
const html = editor.getHTML();
|
||||
const serializedContent = convert(html, {
|
||||
selectors: [
|
||||
{ selector: 'a', options: { linkBrackets: false } },
|
||||
{ selector: 'img', options: { linkBrackets: false } },
|
||||
],
|
||||
});
|
||||
|
||||
// publish message
|
||||
await publish({ content: serializedContent, kind: 1, tags });
|
||||
|
||||
// close modal
|
||||
setLoading(false);
|
||||
toggle(false);
|
||||
// send native notifiation
|
||||
await sendNativeNotification('Publish post successfully');
|
||||
|
||||
// update state
|
||||
setStatus('done');
|
||||
} catch {
|
||||
setLoading(false);
|
||||
setStatus(null);
|
||||
console.log('failed to publish');
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
getCurrent().listen(TauriEvent.WINDOW_FILE_DROP, (event) => {
|
||||
const filepath: string = event.payload[0];
|
||||
if (filepath.match(/\.(jpg|jpeg|png|gif)$/gi)) {
|
||||
// open modal
|
||||
toggleModal(true);
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col px-4 pb-4">
|
||||
<div className="flex h-full w-full gap-2">
|
||||
<div className="flex h-full w-full gap-3">
|
||||
<div className="flex w-8 shrink-0 items-center justify-center">
|
||||
<div className="h-full w-[2px] bg-zinc-800" />
|
||||
</div>
|
||||
<div className="w-full">
|
||||
<EditorContent editor={editor} />
|
||||
<EditorContent
|
||||
editor={editor}
|
||||
spellCheck="false"
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
autoCapitalize="off"
|
||||
/>
|
||||
{reply.id && (
|
||||
<div className="relative">
|
||||
<MentionNote id={reply.id} />
|
||||
@@ -107,9 +155,15 @@ export function Composer() {
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 flex items-center justify-between">
|
||||
<div />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => uploadImage()}
|
||||
className="inline-flex h-8 w-8 items-center justify-center rounded-md hover:bg-zinc-800"
|
||||
>
|
||||
<PlusCircleIcon className="h-5 w-5 text-zinc-500" />
|
||||
</button>
|
||||
<Button onClick={() => submit()} preset="publish">
|
||||
{loading ? (
|
||||
{status === 'loading' ? (
|
||||
<LoaderIcon className="h-4 w-4 animate-spin text-zinc-100" />
|
||||
) : (
|
||||
'Publish'
|
||||
|
||||
@@ -23,7 +23,7 @@ export function MentionItem({ profile }: { profile: Profile }) {
|
||||
)}
|
||||
</h5>
|
||||
<span className="text-sm leading-none text-zinc-400">
|
||||
{profile.nip05 || profile.username || displayNpub(profile.pubkey, 16)}
|
||||
{displayNpub(profile.pubkey, 16)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -8,8 +8,8 @@ export function ComposerUser({ pubkey }: { pubkey: string }) {
|
||||
const { user } = useProfile(pubkey);
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-8 w-8 shrink-0 overflow-hidden rounded bg-zinc-900">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-8 w-8 shrink-0 overflow-hidden rounded-md bg-zinc-900">
|
||||
<Image
|
||||
src={user?.picture || user?.image}
|
||||
fallback={DEFAULT_AVATAR}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { readBinaryFile } from '@tauri-apps/api/fs';
|
||||
|
||||
export async function createBlobFromFile(path: string): Promise<Blob> {
|
||||
export async function createBlobFromFile(path: string): Promise<Uint8Array> {
|
||||
const file = await readBinaryFile(path);
|
||||
return new Blob([file]);
|
||||
return file;
|
||||
}
|
||||
|
||||
82
src/utils/hooks/useUploader.tsx
Normal file
82
src/utils/hooks/useUploader.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
import { open } from '@tauri-apps/api/dialog';
|
||||
import { Body, fetch } from '@tauri-apps/api/http';
|
||||
|
||||
import { createBlobFromFile } from '@utils/createBlobFromFile';
|
||||
|
||||
interface UploadResponse {
|
||||
fileID?: string;
|
||||
fileName?: string;
|
||||
imageUrl?: string;
|
||||
lightningDestination?: string;
|
||||
lightningPaymentLink?: string;
|
||||
message?: string;
|
||||
route?: string;
|
||||
status: number;
|
||||
success: boolean;
|
||||
url?: string;
|
||||
data?: {
|
||||
url?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export function useImageUploader() {
|
||||
const upload = async (file: null | string, nip94?: boolean) => {
|
||||
let filepath = file;
|
||||
|
||||
if (!file) {
|
||||
const selected = await open({
|
||||
multiple: false,
|
||||
filters: [
|
||||
{
|
||||
name: 'Image',
|
||||
extensions: ['png', 'jpeg', 'jpg', 'gif'],
|
||||
},
|
||||
],
|
||||
});
|
||||
if (Array.isArray(selected)) {
|
||||
// user selected multiple files
|
||||
} else if (selected === null) {
|
||||
// user cancelled the selection
|
||||
} else {
|
||||
filepath = selected;
|
||||
}
|
||||
}
|
||||
|
||||
const filename = filepath.split('/').pop();
|
||||
const filetype = 'image/' + filename.split('.').pop();
|
||||
|
||||
const blob = await createBlobFromFile(filepath);
|
||||
const res = await fetch('https://nostrimg.com/api/upload', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
body: Body.form({
|
||||
keys: filename,
|
||||
image: {
|
||||
file: blob,
|
||||
mime: filetype,
|
||||
fileName: filename,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
const data = res.data as UploadResponse;
|
||||
if (typeof data?.imageUrl === 'string' && data.success) {
|
||||
if (nip94) {
|
||||
console.log('todo');
|
||||
}
|
||||
return {
|
||||
url: new URL(data.imageUrl).toString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
error: 'Upload failed',
|
||||
};
|
||||
};
|
||||
|
||||
return upload;
|
||||
}
|
||||
Reference in New Issue
Block a user