update composer with image upload

This commit is contained in:
Ren Amamiya
2023-07-22 15:32:34 +07:00
parent 17d2a8cb56
commit 20a8ce9cba
14 changed files with 261 additions and 36 deletions

View File

@@ -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"

View File

@@ -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) */

View File

@@ -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;

View File

@@ -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>
);

View File

@@ -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'

View File

@@ -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>

View File

@@ -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}

View File

@@ -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;
}

View 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;
}