mirror of
https://github.com/luminous-devs/lume.git
synced 2024-10-02 09:50:47 +00:00
wip
This commit is contained in:
parent
b1a44f2cbf
commit
854a47f266
@ -50,9 +50,12 @@
|
||||
"@tauri-apps/plugin-upload": "2.0.0-alpha.1",
|
||||
"@tauri-apps/plugin-window": "2.0.0-alpha.1",
|
||||
"@tiptap/extension-character-count": "^2.1.12",
|
||||
"@tiptap/extension-document": "^2.1.12",
|
||||
"@tiptap/extension-image": "^2.1.12",
|
||||
"@tiptap/extension-mention": "^2.1.12",
|
||||
"@tiptap/extension-paragraph": "^2.1.12",
|
||||
"@tiptap/extension-placeholder": "^2.1.12",
|
||||
"@tiptap/extension-text": "^2.1.12",
|
||||
"@tiptap/pm": "^2.1.12",
|
||||
"@tiptap/react": "^2.1.12",
|
||||
"@tiptap/starter-kit": "^2.1.12",
|
||||
|
@ -101,15 +101,24 @@ dependencies:
|
||||
'@tiptap/extension-character-count':
|
||||
specifier: ^2.1.12
|
||||
version: 2.1.12(@tiptap/core@2.1.12)(@tiptap/pm@2.1.12)
|
||||
'@tiptap/extension-document':
|
||||
specifier: ^2.1.12
|
||||
version: 2.1.12(@tiptap/core@2.1.12)
|
||||
'@tiptap/extension-image':
|
||||
specifier: ^2.1.12
|
||||
version: 2.1.12(@tiptap/core@2.1.12)
|
||||
'@tiptap/extension-mention':
|
||||
specifier: ^2.1.12
|
||||
version: 2.1.12(@tiptap/core@2.1.12)(@tiptap/pm@2.1.12)(@tiptap/suggestion@2.1.12)
|
||||
'@tiptap/extension-paragraph':
|
||||
specifier: ^2.1.12
|
||||
version: 2.1.12(@tiptap/core@2.1.12)
|
||||
'@tiptap/extension-placeholder':
|
||||
specifier: ^2.1.12
|
||||
version: 2.1.12(@tiptap/core@2.1.12)(@tiptap/pm@2.1.12)
|
||||
'@tiptap/extension-text':
|
||||
specifier: ^2.1.12
|
||||
version: 2.1.12(@tiptap/core@2.1.12)
|
||||
'@tiptap/pm':
|
||||
specifier: ^2.1.12
|
||||
version: 2.1.12
|
||||
|
@ -4,6 +4,14 @@
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer utilities {
|
||||
.break-p {
|
||||
word-break: break-word;
|
||||
word-wrap: break-word;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
}
|
||||
|
||||
html {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
36
src/app.tsx
36
src/app.tsx
@ -6,6 +6,7 @@ import { OnboardingScreen } from '@app/auth/onboarding';
|
||||
import { ChatsScreen } from '@app/chats';
|
||||
import { ErrorScreen } from '@app/error';
|
||||
import { ExploreScreen } from '@app/explore';
|
||||
import { NewScreen } from '@app/new';
|
||||
|
||||
import { useStorage } from '@libs/storage/provider';
|
||||
|
||||
@ -118,18 +119,39 @@ export default function App() {
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: '/new',
|
||||
element: <NewScreen />,
|
||||
errorElement: <ErrorScreen />,
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
async lazy() {
|
||||
const { NewPostScreen } = await import('@app/new/post');
|
||||
return { Component: NewPostScreen };
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'article',
|
||||
async lazy() {
|
||||
const { NewArticleScreen } = await import('@app/new/article');
|
||||
return { Component: NewArticleScreen };
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'file',
|
||||
async lazy() {
|
||||
const { NewFileScreen } = await import('@app/new/file');
|
||||
return { Component: NewFileScreen };
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: '/notes',
|
||||
element: <NoteLayout />,
|
||||
errorElement: <ErrorScreen />,
|
||||
children: [
|
||||
{
|
||||
path: 'new',
|
||||
async lazy() {
|
||||
const { NewNoteScreen } = await import('@app/notes/new');
|
||||
return { Component: NewNoteScreen };
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'text/:id',
|
||||
async lazy() {
|
||||
|
@ -1,11 +1,12 @@
|
||||
import { NDKEvent, NDKKind } from '@nostr-dev-kit/ndk';
|
||||
import { nip04 } from 'nostr-tools';
|
||||
import { useCallback, useState } from 'react';
|
||||
|
||||
import { MediaUploader } from '@app/chats/components/mediaUploader';
|
||||
|
||||
import { EnterIcon } from '@shared/icons';
|
||||
import { useNDK } from '@libs/ndk/provider';
|
||||
|
||||
import { useNostr } from '@utils/hooks/useNostr';
|
||||
import { EnterIcon } from '@shared/icons';
|
||||
|
||||
export function ChatForm({
|
||||
receiverPubkey,
|
||||
@ -15,7 +16,7 @@ export function ChatForm({
|
||||
userPubkey: string;
|
||||
userPrivkey: string;
|
||||
}) {
|
||||
const { publish } = useNostr();
|
||||
const { ndk } = useNDK();
|
||||
const [value, setValue] = useState('');
|
||||
|
||||
const encryptMessage = useCallback(async () => {
|
||||
@ -26,8 +27,12 @@ export function ChatForm({
|
||||
const message = await encryptMessage();
|
||||
const tags = [['p', receiverPubkey]];
|
||||
|
||||
// publish message
|
||||
await publish({ content: message, kind: 4, tags });
|
||||
const event = new NDKEvent(ndk);
|
||||
event.content = message;
|
||||
event.kind = NDKKind.EncryptedDirectMessage;
|
||||
event.tags = tags;
|
||||
|
||||
await event.publish();
|
||||
|
||||
// reset state
|
||||
setValue('');
|
||||
|
270
src/app/new/article.tsx
Normal file
270
src/app/new/article.tsx
Normal file
@ -0,0 +1,270 @@
|
||||
import { NDKEvent, NDKKind } from '@nostr-dev-kit/ndk';
|
||||
import CharacterCount from '@tiptap/extension-character-count';
|
||||
import Image from '@tiptap/extension-image';
|
||||
import Placeholder from '@tiptap/extension-placeholder';
|
||||
import { EditorContent, FloatingMenu, useEditor } from '@tiptap/react';
|
||||
import StarterKit from '@tiptap/starter-kit';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
import { Markdown } from 'tiptap-markdown';
|
||||
|
||||
import { ArticleCoverUploader, MediaUploader, MentionPopup } from '@app/new/components';
|
||||
|
||||
import { useNDK } from '@libs/ndk/provider';
|
||||
|
||||
import {
|
||||
BoldIcon,
|
||||
Heading1Icon,
|
||||
Heading2Icon,
|
||||
Heading3Icon,
|
||||
ItalicIcon,
|
||||
LoaderIcon,
|
||||
ThreadsIcon,
|
||||
} from '@shared/icons';
|
||||
|
||||
export function NewArticleScreen() {
|
||||
const { ndk } = useNDK();
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [title, setTitle] = useState('');
|
||||
const [summary, setSummary] = useState({ open: false, content: '' });
|
||||
const [cover, setCover] = useState('');
|
||||
|
||||
const ident = useMemo(() => String(Date.now()), []);
|
||||
const editor = useEditor({
|
||||
extensions: [
|
||||
StarterKit.configure(),
|
||||
Placeholder.configure({ placeholder: 'Type something...' }),
|
||||
Image.configure({
|
||||
HTMLAttributes: {
|
||||
class:
|
||||
'rounded-lg w-full object-cover h-auto max-h-[400px] border border-neutral-200 dark:border-neutral-800 outline outline-1 outline-offset-0 outline-neutral-300 dark:outline-neutral-700',
|
||||
},
|
||||
}),
|
||||
CharacterCount.configure(),
|
||||
Markdown.configure({
|
||||
html: false,
|
||||
tightLists: true,
|
||||
linkify: true,
|
||||
transformPastedText: true,
|
||||
}),
|
||||
],
|
||||
content: JSON.parse(localStorage.getItem('editor-post') || '{}'),
|
||||
editorProps: {
|
||||
attributes: {
|
||||
class:
|
||||
'outline-none prose prose-lg prose-neutral max-w-none select-text whitespace-pre-line break-words dark:prose-invert hover:prose-a:text-blue-500',
|
||||
},
|
||||
},
|
||||
onUpdate: ({ editor }) => {
|
||||
const jsonContent = JSON.stringify(editor.getJSON());
|
||||
localStorage.setItem('editor-article', jsonContent);
|
||||
},
|
||||
});
|
||||
|
||||
const submit = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
// get markdown content
|
||||
const content = editor.storage.markdown.getMarkdown();
|
||||
|
||||
// define tags
|
||||
const tags: string[][] = [
|
||||
['d', ident],
|
||||
['title', title],
|
||||
['image', cover],
|
||||
['summary', summary.content],
|
||||
['published_at', String(Math.floor(Date.now() / 1000))],
|
||||
];
|
||||
|
||||
// add hashtag to tags if present
|
||||
const hashtags = content.split(/\s/gm).filter((s: string) => s.startsWith('#'));
|
||||
hashtags?.forEach((tag: string) => {
|
||||
tags.push(['t', tag.replace('#', '')]);
|
||||
});
|
||||
|
||||
// publish message
|
||||
const event = new NDKEvent(ndk);
|
||||
event.content = content;
|
||||
event.kind = NDKKind.Article;
|
||||
event.tags = tags;
|
||||
|
||||
const publishedRelays = await event.publish();
|
||||
if (publishedRelays) {
|
||||
toast.success(`Broadcasted to ${publishedRelays.size} relays successfully.`);
|
||||
// update state
|
||||
setLoading(false);
|
||||
// reset editor
|
||||
editor.commands.clearContent();
|
||||
localStorage.setItem('editor-article', '{}');
|
||||
}
|
||||
} catch (e) {
|
||||
setLoading(false);
|
||||
toast.error(e);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col justify-between">
|
||||
<div className="flex flex-col gap-4">
|
||||
{cover ? (
|
||||
<img
|
||||
src={cover}
|
||||
alt="post cover"
|
||||
className="h-72 w-full rounded-lg object-cover"
|
||||
/>
|
||||
) : null}
|
||||
<div className="group flex justify-between gap-2">
|
||||
<input
|
||||
name="title"
|
||||
className="h-9 flex-1 border-none bg-transparent text-2xl font-semibold text-neutral-900 shadow-none outline-none placeholder:text-neutral-400 dark:text-neutral-100 dark:placeholder:text-neutral-600"
|
||||
placeholder="Untitled"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
/>
|
||||
<div
|
||||
className={twMerge(
|
||||
'inline-flex shrink-0 gap-2 group-hover:inline-flex',
|
||||
title.length > 0 ? '' : 'hidden'
|
||||
)}
|
||||
>
|
||||
<ArticleCoverUploader setCover={setCover} />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSummary((prev) => ({ ...prev, open: !prev.open }))}
|
||||
className="inline-flex h-9 w-max items-center gap-2 rounded-lg bg-neutral-100 px-2.5 text-sm font-medium hover:bg-neutral-200 dark:bg-neutral-800 dark:hover:bg-neutral-800"
|
||||
>
|
||||
<ThreadsIcon className="h-4 w-4" />
|
||||
Add summary
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{summary.open ? (
|
||||
<div className="flex gap-3">
|
||||
<div className="h-16 w-1 shrink-0 rounded-full bg-neutral-200 dark:bg-neutral-800" />
|
||||
<div className="flex-1">
|
||||
<textarea
|
||||
className="h-16 w-full border-none bg-transparent px-1 py-1 text-neutral-900 shadow-none outline-none placeholder:text-neutral-400 dark:text-neutral-100 dark:placeholder:text-neutral-600"
|
||||
placeholder="A brief summary of your article"
|
||||
value={summary.content}
|
||||
onChange={(e) =>
|
||||
setSummary((prev) => ({ ...prev, content: e.target.value }))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
<div>
|
||||
{editor && (
|
||||
<FloatingMenu
|
||||
editor={editor}
|
||||
tippyOptions={{ duration: 100 }}
|
||||
className="ml-36 inline-flex h-10 items-center gap-1 rounded-lg border border-neutral-200 bg-neutral-100 px-px dark:border-neutral-800 dark:bg-neutral-900"
|
||||
>
|
||||
<button
|
||||
onClick={() => editor.chain().focus().toggleHeading({ level: 1 }).run()}
|
||||
className={twMerge(
|
||||
'inline-flex h-9 w-9 items-center justify-center rounded-md text-neutral-900 hover:bg-neutral-50 dark:text-neutral-100 dark:hover:bg-neutral-950',
|
||||
editor.isActive('heading', { level: 1 })
|
||||
? 'bg-white shadow dark:bg-black'
|
||||
: ''
|
||||
)}
|
||||
>
|
||||
<Heading1Icon className="h-5 w-5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}
|
||||
className={twMerge(
|
||||
'inline-flex h-9 w-9 items-center justify-center rounded-md text-neutral-900 hover:bg-neutral-50 dark:text-neutral-100 dark:hover:bg-neutral-950',
|
||||
editor.isActive('heading', { level: 2 })
|
||||
? 'bg-white shadow dark:bg-black'
|
||||
: ''
|
||||
)}
|
||||
>
|
||||
<Heading2Icon className="h-5 w-5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}
|
||||
className={twMerge(
|
||||
'inline-flex h-9 w-9 items-center justify-center rounded-md text-neutral-900 hover:bg-neutral-50 dark:text-neutral-100 dark:hover:bg-neutral-950',
|
||||
editor.isActive('heading', { level: 3 })
|
||||
? 'bg-white shadow dark:bg-black'
|
||||
: ''
|
||||
)}
|
||||
>
|
||||
<Heading3Icon className="h-5 w-5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => editor.chain().focus().toggleBold().run()}
|
||||
className={twMerge(
|
||||
'inline-flex h-9 w-9 items-center justify-center rounded-md text-neutral-900 hover:bg-neutral-50 dark:text-neutral-100 dark:hover:bg-neutral-950',
|
||||
editor.isActive('bold') ? 'bg-white shadow dark:bg-black' : ''
|
||||
)}
|
||||
>
|
||||
<BoldIcon className="h-5 w-5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => editor.chain().focus().toggleItalic().run()}
|
||||
className={twMerge(
|
||||
'inline-flex h-9 w-9 items-center justify-center rounded-md text-neutral-900 hover:bg-neutral-50 dark:text-neutral-100 dark:hover:bg-neutral-950',
|
||||
editor.isActive('italic') ? 'bg-white shadow dark:bg-black' : ''
|
||||
)}
|
||||
>
|
||||
<ItalicIcon className="h-5 w-5" />
|
||||
</button>
|
||||
</FloatingMenu>
|
||||
)}
|
||||
<EditorContent
|
||||
editor={editor}
|
||||
spellCheck="false"
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
autoCapitalize="off"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="mb-3 flex h-12 w-full items-center rounded-lg bg-yellow-100 px-3 text-yellow-700">
|
||||
<p className="text-sm">
|
||||
Article editor is still in beta. If you need a stable and more reliable
|
||||
feature, you can use <b>Habla (habla.news)</b> instead.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex h-16 w-full items-center justify-between border-t border-neutral-100 dark:border-neutral-900">
|
||||
<div className="inline-flex items-center gap-3">
|
||||
<span className="text-sm font-medium tabular-nums text-neutral-600 dark:text-neutral-400">
|
||||
{editor?.storage?.characterCount.characters()}
|
||||
</span>
|
||||
<span className="text-sm font-medium tabular-nums text-neutral-600 dark:text-neutral-400">
|
||||
-
|
||||
</span>
|
||||
<span className="text-sm font-medium tabular-nums text-neutral-600 dark:text-neutral-400">
|
||||
<b>Identifier:</b>
|
||||
{ident}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<div className="inline-flex items-center gap-2">
|
||||
<MediaUploader editor={editor} />
|
||||
<MentionPopup editor={editor} />
|
||||
</div>
|
||||
<div className="mx-3 h-6 w-px bg-neutral-200 dark:bg-neutral-800" />
|
||||
<button
|
||||
onClick={() => submit()}
|
||||
disabled={editor && editor.isEmpty}
|
||||
className="inline-flex h-9 w-max items-center justify-center rounded-lg bg-blue-500 px-2.5 font-medium text-white hover:bg-blue-600 disabled:opacity-50"
|
||||
>
|
||||
{loading === true ? (
|
||||
<LoaderIcon className="h-5 w-5 animate-spin" />
|
||||
) : (
|
||||
'Publish article'
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
84
src/app/new/components/articleCoverUploader.tsx
Normal file
84
src/app/new/components/articleCoverUploader.tsx
Normal file
@ -0,0 +1,84 @@
|
||||
import { message, open } from '@tauri-apps/plugin-dialog';
|
||||
import { readBinaryFile } from '@tauri-apps/plugin-fs';
|
||||
import { useState } from 'react';
|
||||
|
||||
import { ImageIcon, LoaderIcon } from '@shared/icons';
|
||||
|
||||
export function ArticleCoverUploader({ setCover }) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const uploadToNostrBuild = async () => {
|
||||
try {
|
||||
// start loading
|
||||
setLoading(true);
|
||||
|
||||
const selected = await open({
|
||||
multiple: false,
|
||||
filters: [
|
||||
{
|
||||
name: 'Media',
|
||||
extensions: [
|
||||
'png',
|
||||
'jpeg',
|
||||
'jpg',
|
||||
'gif',
|
||||
'mp4',
|
||||
'mp3',
|
||||
'webm',
|
||||
'mkv',
|
||||
'avi',
|
||||
'mov',
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
if (!selected) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const file = await readBinaryFile(selected.path);
|
||||
const blob = new Blob([file]);
|
||||
|
||||
const data = new FormData();
|
||||
data.append('fileToUpload', blob);
|
||||
data.append('submit', 'Upload Image');
|
||||
|
||||
const res = await fetch('https://nostr.build/api/v2/upload/files', {
|
||||
method: 'POST',
|
||||
body: data,
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
const json = await res.json();
|
||||
const content = json.data[0];
|
||||
setCover(content.url);
|
||||
|
||||
// stop loading
|
||||
setLoading(false);
|
||||
}
|
||||
} catch (e) {
|
||||
// stop loading
|
||||
setLoading(false);
|
||||
await message(`Upload failed, error: ${e}`, { title: 'Lume', type: 'error' });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={uploadToNostrBuild}
|
||||
className="inline-flex h-9 w-max items-center justify-center gap-2 rounded-lg bg-neutral-100 px-2.5 text-sm font-medium hover:bg-neutral-200 dark:bg-neutral-800 dark:hover:bg-neutral-800"
|
||||
>
|
||||
{loading ? (
|
||||
<LoaderIcon className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<>
|
||||
<ImageIcon className="h-4 w-4" />
|
||||
Add cover
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
4
src/app/new/components/index.ts
Normal file
4
src/app/new/components/index.ts
Normal file
@ -0,0 +1,4 @@
|
||||
export * from './articleCoverUploader';
|
||||
export * from './mediaUploader';
|
||||
export * from './mentionPopup';
|
||||
export * from './mentionPopupItem';
|
@ -2,9 +2,10 @@ import * as Popover from '@radix-ui/react-popover';
|
||||
import { Editor } from '@tiptap/react';
|
||||
import { nip19 } from 'nostr-tools';
|
||||
|
||||
import { MentionPopupItem } from '@app/new/components';
|
||||
|
||||
import { useStorage } from '@libs/storage/provider';
|
||||
|
||||
import { MentionItem } from '@shared/composer';
|
||||
import { MentionIcon } from '@shared/icons';
|
||||
|
||||
export function MentionPopup({ editor }: { editor: Editor }) {
|
||||
@ -34,7 +35,7 @@ export function MentionPopup({ editor }: { editor: Editor }) {
|
||||
{db.account.follows.length > 0 ? (
|
||||
db.account.follows.map((item) => (
|
||||
<button key={item} type="button" onClick={() => insertMention(item)}>
|
||||
<MentionItem pubkey={item} />
|
||||
<MentionPopupItem pubkey={item} />
|
||||
</button>
|
||||
))
|
||||
) : (
|
@ -3,7 +3,7 @@ import { Image } from '@shared/image';
|
||||
import { useProfile } from '@utils/hooks/useProfile';
|
||||
import { displayNpub } from '@utils/shortenKey';
|
||||
|
||||
export function MentionItem({ pubkey, embed }: { pubkey: string; embed?: string }) {
|
||||
export function MentionPopupItem({ pubkey, embed }: { pubkey: string; embed?: string }) {
|
||||
const { status, user } = useProfile(pubkey, embed);
|
||||
|
||||
if (status === 'loading') {
|
@ -8,12 +8,12 @@ import { useNDK } from '@libs/ndk/provider';
|
||||
|
||||
import { LoaderIcon } from '@shared/icons';
|
||||
|
||||
export function FileEditor() {
|
||||
export function NewFileScreen() {
|
||||
const { ndk } = useNDK();
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [isPublish, setIsPublish] = useState(false);
|
||||
const [metadata, setMetadata] = useState(null);
|
||||
const [metadata, setMetadata] = useState<string[][] | null>(null);
|
||||
const [caption, setCaption] = useState('');
|
||||
|
||||
const uploadFile = async () => {
|
||||
@ -105,11 +105,11 @@ export function FileEditor() {
|
||||
|
||||
return (
|
||||
<div className="h-full">
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex h-96 gap-4 rounded-xl bg-neutral-100 p-3 dark:bg-neutral-900">
|
||||
<button
|
||||
type="button"
|
||||
onClick={uploadFile}
|
||||
className="flex h-72 w-full flex-col items-center justify-center rounded-xl border-2 border-dashed border-neutral-200 bg-neutral-100 hover:border-blue-500 hover:text-blue-500 dark:border-neutral-800 dark:bg-neutral-900"
|
||||
className="flex h-full flex-1 flex-col items-center justify-center rounded-lg border border-dashed border-neutral-200 bg-neutral-50 p-2 hover:border-blue-500 hover:text-blue-500 dark:border-neutral-800 dark:bg-neutral-950"
|
||||
>
|
||||
{loading ? (
|
||||
<LoaderIcon className="h-5 w-5 animate-spin text-neutral-900 dark:text-neutral-100" />
|
||||
@ -127,13 +127,24 @@ export function FileEditor() {
|
||||
<img
|
||||
src={metadata[0][1]}
|
||||
alt={metadata[1][1]}
|
||||
className="h-56 w-56 rounded-lg object-cover shadow-lg"
|
||||
className="aspect-square h-full w-full rounded-lg object-cover shadow-lg"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
<div className="mx-auto w-full max-w-sm">
|
||||
<div className="inline-flex w-full items-center gap-2">
|
||||
{metadata ? (
|
||||
<div className="flex h-full flex-1 flex-col justify-between">
|
||||
<div className="flex flex-col gap-2 py-2">
|
||||
{metadata.map((item, index) => (
|
||||
<div key={index} className="flex min-w-0 gap-2">
|
||||
<h5 className="w-24 shrink-0 truncate font-semibold capitalize text-neutral-600 dark:text-neutral-400">
|
||||
{item[0]}
|
||||
</h5>
|
||||
<p className="w-72 truncate">{item[1]}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<input
|
||||
name="caption"
|
||||
type="text"
|
||||
@ -144,18 +155,19 @@ export function FileEditor() {
|
||||
autoCorrect="off"
|
||||
autoCapitalize="off"
|
||||
placeholder="Caption (Optional)..."
|
||||
className="h-11 flex-1 rounded-lg bg-neutral-100 px-3 placeholder:text-neutral-500 dark:bg-neutral-900 dark:placeholder:text-neutral-400"
|
||||
className="h-11 w-full rounded-lg bg-neutral-200 px-3 placeholder:text-neutral-500 dark:bg-neutral-900 dark:placeholder:text-neutral-400"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={submit}
|
||||
disabled={!metadata}
|
||||
className="inline-flex h-11 w-20 shrink-0 items-center justify-center rounded-lg bg-blue-500 font-semibold text-white hover:bg-blue-600 disabled:opacity-50"
|
||||
className="inline-flex h-9 w-full shrink-0 items-center justify-center rounded-lg bg-blue-500 font-semibold text-white hover:bg-blue-600 disabled:opacity-50"
|
||||
>
|
||||
{isPublish ? <LoaderIcon className="h-4 w-4 animate-spin" /> : 'Share'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
77
src/app/new/index.tsx
Normal file
77
src/app/new/index.tsx
Normal file
@ -0,0 +1,77 @@
|
||||
import { Link, NavLink, Outlet } from 'react-router-dom';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
import { WindowTitlebar } from 'tauri-controls';
|
||||
|
||||
import { useStorage } from '@libs/storage/provider';
|
||||
|
||||
import { ArrowLeftIcon } from '@shared/icons';
|
||||
|
||||
export function NewScreen() {
|
||||
const { db } = useStorage();
|
||||
|
||||
return (
|
||||
<div className="flex h-screen w-screen flex-col bg-neutral-50 dark:bg-neutral-950">
|
||||
{db.platform !== 'macos' ? (
|
||||
<WindowTitlebar />
|
||||
) : (
|
||||
<div data-tauri-drag-region className="h-9" />
|
||||
)}
|
||||
<div data-tauri-drag-region className="h-6" />
|
||||
<div className="flex h-full min-h-0 w-full">
|
||||
<div className="container mx-auto grid grid-cols-8 px-4">
|
||||
<div className="col-span-1">
|
||||
<Link
|
||||
to="/"
|
||||
className="inline-flex h-10 w-10 items-center justify-center rounded-lg bg-neutral-100 hover:bg-neutral-200 dark:bg-neutral-900"
|
||||
>
|
||||
<ArrowLeftIcon className="h-5 w-5" />
|
||||
</Link>
|
||||
</div>
|
||||
<div className="relative col-span-6 flex flex-col">
|
||||
<div className="mb-8 flex h-10 shrink-0 items-center gap-3">
|
||||
<div className="flex h-10 items-center gap-2 rounded-lg bg-neutral-100 px-0.5 dark:bg-neutral-800">
|
||||
<NavLink
|
||||
to="/new/"
|
||||
className={({ isActive }) =>
|
||||
twMerge(
|
||||
'inline-flex h-9 w-20 items-center justify-center rounded-lg text-sm font-medium',
|
||||
isActive ? 'bg-white shadow dark:bg-black' : 'bg-transparent'
|
||||
)
|
||||
}
|
||||
>
|
||||
Post
|
||||
</NavLink>
|
||||
<NavLink
|
||||
to="/new/article"
|
||||
className={({ isActive }) =>
|
||||
twMerge(
|
||||
'inline-flex h-9 w-20 items-center justify-center rounded-lg text-sm font-medium',
|
||||
isActive ? 'bg-white shadow dark:bg-black' : 'bg-transparent'
|
||||
)
|
||||
}
|
||||
>
|
||||
Article
|
||||
</NavLink>
|
||||
<NavLink
|
||||
to="/new/file"
|
||||
className={({ isActive }) =>
|
||||
twMerge(
|
||||
'inline-flex h-9 w-28 items-center justify-center rounded-lg text-sm font-medium',
|
||||
isActive ? 'bg-white shadow dark:bg-black' : 'bg-transparent'
|
||||
)
|
||||
}
|
||||
>
|
||||
File Sharing
|
||||
</NavLink>
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-full min-h-0 w-full">
|
||||
<Outlet />
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-span-1" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
@ -5,17 +5,22 @@ 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 { useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import { MediaUploader, MentionPopup } from '@app/new/components';
|
||||
|
||||
import { useNDK } from '@libs/ndk/provider';
|
||||
|
||||
import { MediaUploader, MentionPopup } from '@shared/composer';
|
||||
import { LoaderIcon } from '@shared/icons';
|
||||
import { CancelIcon, LoaderIcon } from '@shared/icons';
|
||||
import { MentionNote } from '@shared/notes';
|
||||
|
||||
export function NewPostScreen() {
|
||||
const { ndk, relayUrls } = useNDK();
|
||||
|
||||
export function PostEditor() {
|
||||
const { ndk } = useNDK();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
|
||||
const editor = useEditor({
|
||||
extensions: [
|
||||
@ -46,6 +51,12 @@ export function PostEditor() {
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
const reply = {
|
||||
id: searchParams.get('id'),
|
||||
root: searchParams.get('root'),
|
||||
pubkey: searchParams.get('pubkey'),
|
||||
};
|
||||
|
||||
// get plaintext content
|
||||
const html = editor.getHTML();
|
||||
const serializedContent = convert(html, {
|
||||
@ -56,12 +67,29 @@ export function PostEditor() {
|
||||
});
|
||||
|
||||
// define tags
|
||||
const tags: string[][] = [];
|
||||
let tags: string[][] = [];
|
||||
|
||||
// add reply to tags if present
|
||||
if (reply.id && reply.pubkey) {
|
||||
if (reply.root && reply.root.length > 1) {
|
||||
tags = [
|
||||
['e', reply.root, relayUrls[0], 'root'],
|
||||
['e', reply.id, relayUrls[0], 'reply'],
|
||||
['p', reply.pubkey],
|
||||
];
|
||||
} else {
|
||||
tags = [
|
||||
['e', reply.id, relayUrls[0], 'reply'],
|
||||
['p', reply.pubkey],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// add hashtag to tags if present
|
||||
const hashtags = serializedContent
|
||||
.split(/\s/gm)
|
||||
.filter((s: string) => s.startsWith('#'));
|
||||
|
||||
hashtags?.forEach((tag: string) => {
|
||||
tags.push(['t', tag.replace('#', '')]);
|
||||
});
|
||||
@ -78,6 +106,7 @@ export function PostEditor() {
|
||||
// update state
|
||||
setLoading(false);
|
||||
// reset editor
|
||||
setSearchParams({});
|
||||
editor.commands.clearContent();
|
||||
localStorage.setItem('editor-post', '{}');
|
||||
}
|
||||
@ -87,8 +116,13 @@ export function PostEditor() {
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (editor) editor.commands.focus('end');
|
||||
}, [editor]);
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col justify-between">
|
||||
<div>
|
||||
<EditorContent
|
||||
editor={editor}
|
||||
spellCheck="false"
|
||||
@ -96,6 +130,19 @@ export function PostEditor() {
|
||||
autoCorrect="off"
|
||||
autoCapitalize="off"
|
||||
/>
|
||||
{searchParams.get('id') && (
|
||||
<div className="relative max-w-lg">
|
||||
<MentionNote id={searchParams.get('id')} />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSearchParams({})}
|
||||
className="absolute right-3 top-3 inline-flex h-6 w-6 items-center justify-center rounded bg-neutral-300 px-2 dark:bg-neutral-700"
|
||||
>
|
||||
<CancelIcon className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex h-16 w-full items-center justify-between border-t border-neutral-100 dark:border-neutral-900">
|
||||
<span className="text-sm font-medium tabular-nums text-neutral-600 dark:text-neutral-400">
|
||||
{editor?.storage?.characterCount.characters()}
|
@ -1,124 +0,0 @@
|
||||
import { NDKEvent, NDKKind } from '@nostr-dev-kit/ndk';
|
||||
import CharacterCount from '@tiptap/extension-character-count';
|
||||
import Image from '@tiptap/extension-image';
|
||||
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 { useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import { useNDK } from '@libs/ndk/provider';
|
||||
|
||||
import { MediaUploader, MentionPopup } from '@shared/composer';
|
||||
import { LoaderIcon } from '@shared/icons';
|
||||
|
||||
export function ArticleEditor() {
|
||||
const { ndk } = useNDK();
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const editor = useEditor({
|
||||
extensions: [
|
||||
StarterKit.configure(),
|
||||
Placeholder.configure({ placeholder: 'Type something...' }),
|
||||
Image.configure({
|
||||
HTMLAttributes: {
|
||||
class:
|
||||
'rounded-lg w-full object-cover h-auto max-h-[400px] border border-neutral-200 dark:border-neutral-800 outline outline-1 outline-offset-0 outline-neutral-300 dark:outline-neutral-700',
|
||||
},
|
||||
}),
|
||||
CharacterCount.configure(),
|
||||
],
|
||||
content: JSON.parse(localStorage.getItem('editor-post') || '{}'),
|
||||
editorProps: {
|
||||
attributes: {
|
||||
class:
|
||||
'outline-none prose prose-lg prose-neutral max-w-none select-text whitespace-pre-line break-words dark:prose-invert hover:prose-a:text-blue-500',
|
||||
},
|
||||
},
|
||||
onUpdate: ({ editor }) => {
|
||||
const jsonContent = JSON.stringify(editor.getJSON());
|
||||
localStorage.setItem('editor-post', jsonContent);
|
||||
},
|
||||
});
|
||||
|
||||
const submit = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
// get plaintext content
|
||||
const html = editor.getHTML();
|
||||
const serializedContent = convert(html, {
|
||||
selectors: [
|
||||
{ selector: 'a', options: { linkBrackets: false } },
|
||||
{ selector: 'img', options: { linkBrackets: false } },
|
||||
],
|
||||
});
|
||||
|
||||
// define tags
|
||||
const tags: string[][] = [];
|
||||
|
||||
// add hashtag to tags if present
|
||||
const hashtags = serializedContent
|
||||
.split(/\s/gm)
|
||||
.filter((s: string) => s.startsWith('#'));
|
||||
hashtags?.forEach((tag: string) => {
|
||||
tags.push(['t', tag.replace('#', '')]);
|
||||
});
|
||||
|
||||
// publish message
|
||||
const event = new NDKEvent(ndk);
|
||||
event.content = serializedContent;
|
||||
event.kind = NDKKind.Article;
|
||||
event.tags = tags;
|
||||
|
||||
const publishedRelays = await event.publish();
|
||||
if (publishedRelays) {
|
||||
toast.success(`Broadcasted to ${publishedRelays.size} relays successfully.`);
|
||||
// update state
|
||||
setLoading(false);
|
||||
// reset editor
|
||||
editor.commands.clearContent();
|
||||
localStorage.setItem('editor-post', '{}');
|
||||
}
|
||||
} catch (e) {
|
||||
setLoading(false);
|
||||
toast.error(e);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col justify-between">
|
||||
<EditorContent
|
||||
editor={editor}
|
||||
spellCheck="false"
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
autoCapitalize="off"
|
||||
/>
|
||||
<div className="flex h-16 w-full items-center justify-between border-t border-neutral-100 dark:border-neutral-900">
|
||||
<span className="text-sm font-medium tabular-nums text-neutral-600 dark:text-neutral-400">
|
||||
{editor?.storage?.characterCount.characters()}
|
||||
</span>
|
||||
<div className="flex items-center">
|
||||
<div className="inline-flex items-center gap-2">
|
||||
<MediaUploader editor={editor} />
|
||||
<MentionPopup editor={editor} />
|
||||
</div>
|
||||
<div className="mx-3 h-6 w-px bg-neutral-200 dark:bg-neutral-800" />
|
||||
<button
|
||||
onClick={() => submit()}
|
||||
disabled={editor && editor.isEmpty}
|
||||
className="inline-flex h-9 w-max items-center justify-center rounded-lg bg-blue-500 px-2.5 font-medium text-white hover:bg-blue-600 disabled:opacity-50"
|
||||
>
|
||||
{loading === true ? (
|
||||
<LoaderIcon className="h-5 w-5 animate-spin" />
|
||||
) : (
|
||||
'Publish article'
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
@ -1,3 +0,0 @@
|
||||
export * from './editor/post';
|
||||
export * from './editor/article';
|
||||
export * from './editor/file';
|
@ -1,85 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
import { ArticleEditor, FileEditor, PostEditor } from '@app/notes/components';
|
||||
|
||||
import { ArrowLeftIcon } from '@shared/icons';
|
||||
|
||||
export function NewNoteScreen() {
|
||||
const [type, setType] = useState<'post' | 'article' | 'file' | 'raw'>('post');
|
||||
|
||||
const renderEditor = () => {
|
||||
switch (type) {
|
||||
case 'post':
|
||||
return <PostEditor />;
|
||||
case 'article':
|
||||
return <ArticleEditor />;
|
||||
case 'file':
|
||||
return <FileEditor />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="container mx-auto grid grid-cols-8 px-4">
|
||||
<div className="col-span-1">
|
||||
<Link
|
||||
to="/"
|
||||
className="inline-flex h-10 w-10 items-center justify-center rounded-lg bg-neutral-100 hover:bg-neutral-200 dark:bg-neutral-900"
|
||||
>
|
||||
<ArrowLeftIcon className="h-5 w-5" />
|
||||
</Link>
|
||||
</div>
|
||||
<div className="relative col-span-6 flex flex-col">
|
||||
<div className="mb-8 flex h-10 shrink-0 items-center gap-3">
|
||||
<div className="flex h-10 items-center gap-2 rounded-lg bg-neutral-100 px-0.5 dark:bg-neutral-800">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setType('post')}
|
||||
className={twMerge(
|
||||
'inline-flex h-9 w-20 items-center justify-center rounded-lg text-sm font-medium',
|
||||
type === 'post' ? 'bg-white shadow' : 'bg-transparent'
|
||||
)}
|
||||
>
|
||||
Post
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setType('article')}
|
||||
className={twMerge(
|
||||
'inline-flex h-9 w-20 items-center justify-center rounded-lg text-sm font-medium',
|
||||
type === 'article' ? 'bg-white shadow' : 'bg-transparent'
|
||||
)}
|
||||
>
|
||||
Article
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setType('file')}
|
||||
className={twMerge(
|
||||
'inline-flex h-9 w-28 items-center justify-center rounded-lg text-sm font-medium',
|
||||
type === 'file' ? 'bg-white shadow' : 'bg-transparent'
|
||||
)}
|
||||
>
|
||||
File Sharing
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setType('raw')}
|
||||
className={twMerge(
|
||||
'inline-flex h-9 w-32 items-center justify-center rounded-lg text-sm font-medium',
|
||||
type === 'raw' ? 'bg-white shadow' : 'bg-transparent'
|
||||
)}
|
||||
>
|
||||
Raw (advance)
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-full min-h-0 w-full">{renderEditor()}</div>
|
||||
</div>
|
||||
<div className="col-span-1" />
|
||||
</div>
|
||||
);
|
||||
}
|
@ -69,7 +69,7 @@ export function NWCAlby() {
|
||||
<Dialog.Trigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex h-9 w-min items-center justify-center rounded-md bg-neutral-300 px-3 text-sm font-medium hover:bg-blue-500 hover:text-white dark:bg-neutral-700"
|
||||
className="inline-flex h-9 w-min items-center justify-center rounded-lg bg-neutral-300 px-3 text-sm font-medium hover:bg-blue-500 hover:text-white dark:bg-neutral-700"
|
||||
>
|
||||
Connect
|
||||
</button>
|
||||
|
@ -86,7 +86,7 @@ export function NWCOther() {
|
||||
<Dialog.Trigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex h-9 w-min items-center justify-center rounded-md bg-neutral-300 px-3 text-sm font-medium hover:bg-blue-500 hover:text-white dark:bg-neutral-700"
|
||||
className="inline-flex h-9 w-min items-center justify-center rounded-lg bg-neutral-300 px-3 text-sm font-medium hover:bg-blue-500 hover:text-white dark:bg-neutral-700"
|
||||
>
|
||||
Connect
|
||||
</button>
|
||||
|
@ -53,7 +53,7 @@ export function RelayList() {
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<VList className="h-full scrollbar-none">
|
||||
<VList className="h-full">
|
||||
<div className="inline-flex h-16 w-full items-center border-b border-neutral-100 px-3 dark:border-neutral-900">
|
||||
<h3 className="font-semibold text-neutral-950 dark:text-neutral-50">
|
||||
All relays used by your follows
|
||||
@ -103,7 +103,6 @@ export function RelayList() {
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<div className="h-16" />
|
||||
</VList>
|
||||
)}
|
||||
</div>
|
||||
|
@ -5,6 +5,7 @@ import Database from '@tauri-apps/plugin-sql';
|
||||
|
||||
import { FULL_RELAYS } from '@stores/constants';
|
||||
|
||||
import { rawEvent } from '@utils/transform';
|
||||
import { Account, DBEvent, Relays, Widget } from '@utils/types';
|
||||
|
||||
export class LumeStorage {
|
||||
@ -140,6 +141,8 @@ export class LumeStorage {
|
||||
}
|
||||
|
||||
public async createEvent(event: NDKEvent) {
|
||||
const rawNostrEvent = rawEvent(event);
|
||||
|
||||
let root: string;
|
||||
let reply: string;
|
||||
|
||||
@ -155,7 +158,7 @@ export class LumeStorage {
|
||||
[
|
||||
event.id,
|
||||
this.account.id,
|
||||
JSON.stringify(event),
|
||||
JSON.stringify(rawNostrEvent),
|
||||
event.pubkey,
|
||||
event.kind,
|
||||
root,
|
||||
|
@ -1,164 +0,0 @@
|
||||
import { NDKEvent, NDKKind } from '@nostr-dev-kit/ndk';
|
||||
import { message } from '@tauri-apps/plugin-dialog';
|
||||
import Image from '@tiptap/extension-image';
|
||||
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 { useState } from 'react';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
import { useNDK } from '@libs/ndk/provider';
|
||||
|
||||
import { MediaUploader, MentionPopup } from '@shared/composer';
|
||||
import { CancelIcon, LoaderIcon } from '@shared/icons';
|
||||
import { MentionNote } from '@shared/notes';
|
||||
|
||||
import { useComposer } from '@stores/composer';
|
||||
|
||||
export function Composer() {
|
||||
const [loading, setLoading] = useState<boolean>(false);
|
||||
const [reply, clearReply] = useComposer((state) => [state.reply, state.clearReply]);
|
||||
|
||||
const { ndk } = useNDK();
|
||||
|
||||
const expand = useComposer((state) => state.expand);
|
||||
const editor = useEditor({
|
||||
extensions: [
|
||||
StarterKit.configure({
|
||||
dropcursor: {
|
||||
color: '#fff',
|
||||
},
|
||||
}),
|
||||
Placeholder.configure({ placeholder: 'Type something...' }),
|
||||
Image.configure({
|
||||
HTMLAttributes: {
|
||||
class:
|
||||
'rounded-lg w-2/3 h-auto border border-white/10 outline outline-2 outline-offset-0 outline-white/20 ml-1',
|
||||
},
|
||||
}),
|
||||
],
|
||||
content: JSON.parse(localStorage.getItem('editor-content') || '{}'),
|
||||
editorProps: {
|
||||
attributes: {
|
||||
class:
|
||||
'h-full prose prose-neutral max-w-none select-text whitespace-pre-line leading-normal dark:prose-invert prose-headings:mb-1 prose-headings:mt-3 prose-p:mb-0 prose-p:mt-0 prose-p:last:mb-1 prose-a:font-normal prose-a:text-blue-500 prose-blockquote:mb-1 prose-blockquote:mt-1 prose-blockquote:border-l-[2px] prose-blockquote:border-blue-500 prose-blockquote:pl-2 prose-pre:whitespace-pre-wrap prose-pre:break-words prose-pre:break-all prose-pre:bg-white/10 prose-ol:m-0 prose-ol:mb-1 prose-ul:mb-1 prose-ul:mt-1 prose-img:mb-2 prose-img:mt-3 prose-hr:mx-0 prose-hr:my-2 hover:prose-a:text-blue-500 break-all overflow-y-auto outline-none pr-2',
|
||||
},
|
||||
},
|
||||
onUpdate: ({ editor }) => {
|
||||
const jsonContent = JSON.stringify(editor.getJSON());
|
||||
localStorage.setItem('editor-content', jsonContent);
|
||||
},
|
||||
});
|
||||
|
||||
const submit = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
// get plaintext content
|
||||
const html = editor.getHTML();
|
||||
const serializedContent = convert(html, {
|
||||
selectors: [
|
||||
{ selector: 'a', options: { linkBrackets: false } },
|
||||
{ selector: 'img', options: { linkBrackets: false } },
|
||||
],
|
||||
});
|
||||
|
||||
// define tags
|
||||
let tags: string[][] = [];
|
||||
|
||||
// add reply to tags if present
|
||||
if (reply.id && reply.pubkey) {
|
||||
if (reply.root && reply.root.length > 1) {
|
||||
tags = [
|
||||
['e', reply.root, '', 'root'],
|
||||
['e', reply.id, '', 'reply'],
|
||||
['p', reply.pubkey],
|
||||
];
|
||||
} else {
|
||||
tags = [
|
||||
['e', reply.id, '', 'reply'],
|
||||
['p', reply.pubkey],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// add hashtag to tags if present
|
||||
const hashtags = serializedContent
|
||||
.split(/\s/gm)
|
||||
.filter((s: string) => s.startsWith('#'));
|
||||
hashtags?.forEach((tag: string) => {
|
||||
tags.push(['t', tag.replace('#', '')]);
|
||||
});
|
||||
|
||||
// publish message
|
||||
const event = new NDKEvent(ndk);
|
||||
event.content = serializedContent;
|
||||
event.kind = NDKKind.Text;
|
||||
event.tags = tags;
|
||||
|
||||
const publish = event.publish();
|
||||
if (publish) {
|
||||
// update state
|
||||
setLoading(false);
|
||||
// reset editor
|
||||
editor.commands.clearContent();
|
||||
// reset reply
|
||||
if (reply.id) {
|
||||
clearReply();
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
setLoading(false);
|
||||
await message('Publishing post failed.', { title: 'Lume', type: 'error' });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="flex h-full w-full gap-3 px-4 pb-4">
|
||||
<div className="flex w-10 shrink-0 items-center justify-center">
|
||||
<div className="h-full w-[2px] bg-neutral-100 dark:bg-neutral-900" />
|
||||
</div>
|
||||
<div className="w-full">
|
||||
<EditorContent
|
||||
editor={editor}
|
||||
spellCheck="false"
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
autoCapitalize="off"
|
||||
className={twMerge(
|
||||
'markdown max-h-[500px] overflow-y-auto break-all pr-2 outline-none scrollbar-none',
|
||||
expand ? 'min-h-[500px]' : reply.id ? 'min-h-min' : 'min-h-[120px]'
|
||||
)}
|
||||
/>
|
||||
{reply.id && (
|
||||
<div className="relative">
|
||||
<MentionNote id={reply.id} />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => clearReply()}
|
||||
className="absolute right-3 top-3 inline-flex h-6 w-6 items-center justify-center rounded bg-neutral-300 px-2 dark:bg-neutral-700"
|
||||
>
|
||||
<CancelIcon className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between rounded-b-xl border-t border-neutral-200 bg-neutral-100 p-2 dark:border-neutral-800 dark:bg-neutral-900">
|
||||
<div className="inline-flex items-center gap-1">
|
||||
<MediaUploader editor={editor} />
|
||||
<MentionPopup editor={editor} />
|
||||
</div>
|
||||
<button
|
||||
onClick={() => submit()}
|
||||
disabled={editor && editor.isEmpty}
|
||||
className="inline-flex h-9 w-20 items-center justify-center rounded-lg bg-blue-500 px-2 font-medium text-white hover:bg-blue-600 disabled:opacity-50"
|
||||
>
|
||||
{loading === true ? <LoaderIcon className="h-5 w-5 animate-spin" /> : 'Post'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
@ -1,8 +0,0 @@
|
||||
export * from './user';
|
||||
export * from './modal';
|
||||
export * from './composer';
|
||||
export * from './mention/item';
|
||||
export * from './mention/popup';
|
||||
export * from './mention/suggestion';
|
||||
export * from './mention/inlineList';
|
||||
export * from './mediaUploader';
|
@ -1,83 +0,0 @@
|
||||
import { type SuggestionProps } from '@tiptap/suggestion';
|
||||
import {
|
||||
ForwardedRef,
|
||||
forwardRef,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
import { MentionItem } from '@shared/composer';
|
||||
|
||||
export const MentionInlineList = forwardRef(
|
||||
(props: SuggestionProps, ref: ForwardedRef<unknown>) => {
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
|
||||
const selectItem = (index) => {
|
||||
const item = props.items[index];
|
||||
|
||||
if (item) {
|
||||
props.command({ id: item });
|
||||
}
|
||||
};
|
||||
|
||||
const upHandler = () => {
|
||||
setSelectedIndex((selectedIndex + props.items.length - 1) % props.items.length);
|
||||
};
|
||||
|
||||
const downHandler = () => {
|
||||
setSelectedIndex((selectedIndex + 1) % props.items.length);
|
||||
};
|
||||
|
||||
const enterHandler = () => {
|
||||
selectItem(selectedIndex);
|
||||
};
|
||||
|
||||
useEffect(() => setSelectedIndex(0), [props.items]);
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
onKeyDown: ({ event }) => {
|
||||
if (event.key === 'ArrowUp') {
|
||||
upHandler();
|
||||
return true;
|
||||
}
|
||||
|
||||
if (event.key === 'ArrowDown') {
|
||||
downHandler();
|
||||
return true;
|
||||
}
|
||||
|
||||
if (event.key === 'Enter') {
|
||||
enterHandler();
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="flex w-[250px] flex-col rounded-xl bg-white/10 px-3 py-3 backdrop-blur-xl">
|
||||
{props.items.length ? (
|
||||
props.items.map((item: string, index: number) => (
|
||||
<button
|
||||
className={twMerge(
|
||||
'h-11 w-full rounded-lg px-2 text-start text-sm font-medium hover:bg-white/10',
|
||||
`${index === selectedIndex ? 'is-selected' : ''}`
|
||||
)}
|
||||
key={index}
|
||||
onClick={() => selectItem(index)}
|
||||
>
|
||||
<MentionItem embed={item} />
|
||||
</button>
|
||||
))
|
||||
) : (
|
||||
<div>No result</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
MentionInlineList.displayName = 'MentionList';
|
@ -1,68 +0,0 @@
|
||||
import { ReactRenderer } from '@tiptap/react';
|
||||
import tippy from 'tippy.js';
|
||||
|
||||
import { MentionInlineList } from '@shared/composer';
|
||||
|
||||
export const Suggestion = {
|
||||
items: async ({ query }) => {
|
||||
const users = [];
|
||||
return users
|
||||
.filter((item) => item.ident.toLowerCase().startsWith(query.toLowerCase()))
|
||||
.slice(0, 5);
|
||||
},
|
||||
|
||||
render: () => {
|
||||
let component;
|
||||
let popup;
|
||||
|
||||
return {
|
||||
onStart: (props) => {
|
||||
component = new ReactRenderer(MentionInlineList, {
|
||||
props,
|
||||
editor: props.editor,
|
||||
});
|
||||
|
||||
if (!props.clientRect) {
|
||||
return;
|
||||
}
|
||||
|
||||
popup = tippy('body', {
|
||||
getReferenceClientRect: props.clientRect,
|
||||
appendTo: () => document.body,
|
||||
content: component.element,
|
||||
showOnCreate: true,
|
||||
interactive: true,
|
||||
trigger: 'manual',
|
||||
placement: 'bottom-start',
|
||||
});
|
||||
},
|
||||
|
||||
onUpdate(props) {
|
||||
component.updateProps(props);
|
||||
|
||||
if (!props.clientRect) {
|
||||
return;
|
||||
}
|
||||
|
||||
popup[0].setProps({
|
||||
getReferenceClientRect: props.clientRect,
|
||||
});
|
||||
},
|
||||
|
||||
onKeyDown(props) {
|
||||
if (props.event.key === 'Escape') {
|
||||
popup[0].hide();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return component.ref?.onKeyDown(props);
|
||||
},
|
||||
|
||||
onExit() {
|
||||
popup[0].destroy();
|
||||
component.destroy();
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
@ -1,52 +0,0 @@
|
||||
import * as Dialog from '@radix-ui/react-dialog';
|
||||
|
||||
import { useStorage } from '@libs/storage/provider';
|
||||
|
||||
import { Composer, ComposerUser } from '@shared/composer';
|
||||
import {
|
||||
CancelIcon,
|
||||
ChevronDownIcon,
|
||||
ChevronRightIcon,
|
||||
ComposeIcon,
|
||||
} from '@shared/icons';
|
||||
|
||||
import { useComposer } from '@stores/composer';
|
||||
|
||||
export function ComposerModal() {
|
||||
const { db } = useStorage();
|
||||
const [toggleModal, open] = useComposer((state) => [state.toggleModal, state.open]);
|
||||
|
||||
return (
|
||||
<Dialog.Root open={open} onOpenChange={toggleModal}>
|
||||
<Dialog.Trigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="flex aspect-square h-auto w-full items-center justify-center rounded-lg bg-neutral-100 text-black hover:bg-blue-500 hover:text-white dark:bg-neutral-900 dark:text-white dark:hover:bg-blue-500"
|
||||
>
|
||||
<ComposeIcon className="h-5 w-5" />
|
||||
</button>
|
||||
</Dialog.Trigger>
|
||||
<Dialog.Portal>
|
||||
<Dialog.Overlay className="fixed inset-0 z-50 bg-black/50 backdrop-blur-xl dark:bg-white/50" />
|
||||
<Dialog.Content className="fixed inset-0 z-50 flex min-h-full items-center justify-center">
|
||||
<div className="relative h-min w-full max-w-2xl rounded-xl bg-white dark:bg-black">
|
||||
<div className="flex items-center justify-between px-4 py-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<ComposerUser pubkey={db.account.pubkey} />
|
||||
<ChevronRightIcon className="h-4 w-4 text-neutral-600 dark:text-neutral-400" />
|
||||
<div className="inline-flex h-7 w-max items-center justify-center gap-0.5 rounded bg-neutral-200 pl-3 pr-1.5 text-sm font-medium text-neutral-900 dark:bg-neutral-800 dark:text-neutral-100">
|
||||
New Post
|
||||
<ChevronDownIcon className="h-4 w-4" />
|
||||
</div>
|
||||
</div>
|
||||
<Dialog.Close className="inline-flex h-10 w-10 items-center justify-center rounded-lg text-neutral-600 hover:bg-neutral-200 hover:text-neutral-500 dark:text-neutral-400 dark:hover:bg-neutral-800 dark:hover:text-neutral-400">
|
||||
<CancelIcon className="h-5 w-5" />
|
||||
</Dialog.Close>
|
||||
</div>
|
||||
<Composer />
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
</Dialog.Portal>
|
||||
</Dialog.Root>
|
||||
);
|
||||
}
|
@ -1,19 +0,0 @@
|
||||
import { useProfile } from '@utils/hooks/useProfile';
|
||||
import { displayNpub } from '@utils/shortenKey';
|
||||
|
||||
export function ComposerUser({ pubkey }: { pubkey: string }) {
|
||||
const { user } = useProfile(pubkey);
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
<img
|
||||
src={user?.picture || user?.image}
|
||||
alt={pubkey}
|
||||
className="h-10 w-10 shrink-0 rounded-lg"
|
||||
/>
|
||||
<h5 className="font-medium text-neutral-900 dark:text-neutral-100">
|
||||
{user?.display_name || user?.name || user?.displayName || displayNpub(pubkey, 16)}
|
||||
</h5>
|
||||
</div>
|
||||
);
|
||||
}
|
22
src/shared/icons/bold.tsx
Normal file
22
src/shared/icons/bold.tsx
Normal file
@ -0,0 +1,22 @@
|
||||
import { SVGProps } from 'react';
|
||||
|
||||
export function BoldIcon(props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
stroke="currentColor"
|
||||
strokeLinecap="square"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
d="M13 12H6m7 0a4 4 0 000-8H8a2 2 0 00-2 2v6m7 0h1a4 4 0 010 8H8a2 2 0 01-2-2v-6"
|
||||
></path>
|
||||
</svg>
|
||||
);
|
||||
}
|
22
src/shared/icons/heading1.tsx
Normal file
22
src/shared/icons/heading1.tsx
Normal file
@ -0,0 +1,22 @@
|
||||
import { SVGProps } from 'react';
|
||||
|
||||
export function Heading1Icon(props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
d="M3 5v7m0 7v-7m10-7v7m0 7v-7m0 0H3m15 1.5l3-2.5v8"
|
||||
></path>
|
||||
</svg>
|
||||
);
|
||||
}
|
22
src/shared/icons/heading2.tsx
Normal file
22
src/shared/icons/heading2.tsx
Normal file
@ -0,0 +1,22 @@
|
||||
import { SVGProps } from 'react';
|
||||
|
||||
export function Heading2Icon(props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
d="M13 5v7m0 0v7m0-7H3m0-7v7m0 0v7m19 0h-4l3.495-4.432A2 2 0 0022 13.24V13a2 2 0 00-3.732-1"
|
||||
></path>
|
||||
</svg>
|
||||
);
|
||||
}
|
22
src/shared/icons/heading3.tsx
Normal file
22
src/shared/icons/heading3.tsx
Normal file
@ -0,0 +1,22 @@
|
||||
import { SVGProps } from 'react';
|
||||
|
||||
export function Heading3Icon(props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
d="M13 5v7m0 0v7m0-7H3m0-7v7m0 0v7m15.268-7A2 2 0 0122 13a2 2 0 01-2 2 2 2 0 11-1.732 3"
|
||||
></path>
|
||||
</svg>
|
||||
);
|
||||
}
|
@ -73,3 +73,8 @@ export * from './explore2';
|
||||
export * from './home';
|
||||
export * from './chats';
|
||||
export * from './community';
|
||||
export * from './heading1';
|
||||
export * from './heading2';
|
||||
export * from './heading3';
|
||||
export * from './bold';
|
||||
export * from './italic';
|
||||
|
22
src/shared/icons/italic.tsx
Normal file
22
src/shared/icons/italic.tsx
Normal file
@ -0,0 +1,22 @@
|
||||
import { SVGProps } from 'react';
|
||||
|
||||
export function ItalicIcon(props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
d="M10 4h4.5M19 4h-4.5m0 0l-5 16m0 0H5m4.5 0H14"
|
||||
></path>
|
||||
</svg>
|
||||
);
|
||||
}
|
@ -104,7 +104,7 @@ export function Navigation() {
|
||||
</div>
|
||||
<div className="flex shrink-0 flex-col gap-3 p-1">
|
||||
<Link
|
||||
to="/notes/new"
|
||||
to="/new/"
|
||||
className="flex aspect-square h-auto w-full items-center justify-center rounded-lg bg-neutral-100 text-black hover:bg-blue-500 hover:text-white dark:bg-neutral-900 dark:text-white dark:hover:bg-blue-500"
|
||||
>
|
||||
<ComposeIcon className="h-5 w-5" />
|
||||
|
@ -1,10 +1,10 @@
|
||||
import { NDKKind } from '@nostr-dev-kit/ndk';
|
||||
import { NDKEvent, NDKKind } from '@nostr-dev-kit/ndk';
|
||||
import * as Popover from '@radix-ui/react-popover';
|
||||
import { useState } from 'react';
|
||||
|
||||
import { ReactionIcon } from '@shared/icons';
|
||||
import { useNDK } from '@libs/ndk/provider';
|
||||
|
||||
import { useNostr } from '@utils/hooks/useNostr';
|
||||
import { ReactionIcon } from '@shared/icons';
|
||||
|
||||
const REACTIONS = [
|
||||
{
|
||||
@ -30,11 +30,11 @@ const REACTIONS = [
|
||||
];
|
||||
|
||||
export function NoteReaction({ id, pubkey }: { id: string; pubkey: string }) {
|
||||
const { ndk } = useNDK();
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
const [reaction, setReaction] = useState<string | null>(null);
|
||||
|
||||
const { publish } = useNostr();
|
||||
|
||||
const getReactionImage = (content: string) => {
|
||||
const reaction: { img: string } = REACTIONS.find((el) => el.content === content);
|
||||
return reaction.img;
|
||||
@ -43,16 +43,16 @@ export function NoteReaction({ id, pubkey }: { id: string; pubkey: string }) {
|
||||
const react = async (content: string) => {
|
||||
setReaction(content);
|
||||
|
||||
const event = await publish({
|
||||
content: content,
|
||||
kind: NDKKind.Reaction,
|
||||
tags: [
|
||||
const event = new NDKEvent(ndk);
|
||||
event.content = content;
|
||||
event.kind = NDKKind.Reaction;
|
||||
event.tags = [
|
||||
['e', id],
|
||||
['p', pubkey],
|
||||
],
|
||||
});
|
||||
];
|
||||
|
||||
if (event) {
|
||||
const publishedRelays = await event.publish();
|
||||
if (publishedRelays) {
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
|
@ -1,9 +1,8 @@
|
||||
import * as Tooltip from '@radix-ui/react-tooltip';
|
||||
import { createSearchParams, useNavigate } from 'react-router-dom';
|
||||
|
||||
import { ReplyIcon } from '@shared/icons';
|
||||
|
||||
import { useComposer } from '@stores/composer';
|
||||
|
||||
export function NoteReply({
|
||||
id,
|
||||
pubkey,
|
||||
@ -13,14 +12,23 @@ export function NoteReply({
|
||||
pubkey: string;
|
||||
root?: string;
|
||||
}) {
|
||||
const setReply = useComposer((state) => state.setReply);
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
<Tooltip.Root delayDuration={150}>
|
||||
<Tooltip.Trigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setReply(id, pubkey, root)}
|
||||
onClick={() =>
|
||||
navigate({
|
||||
pathname: '/new/',
|
||||
search: createSearchParams({
|
||||
id,
|
||||
pubkey,
|
||||
root,
|
||||
}).toString(),
|
||||
})
|
||||
}
|
||||
className="group inline-flex h-7 w-7 items-center justify-center text-neutral-600 dark:text-neutral-400"
|
||||
>
|
||||
<ReplyIcon className="h-5 w-5 group-hover:text-blue-500" />
|
||||
|
@ -1,20 +1,16 @@
|
||||
import { NDKKind } from '@nostr-dev-kit/ndk';
|
||||
import { NDKEvent, NDKKind } from '@nostr-dev-kit/ndk';
|
||||
import * as AlertDialog from '@radix-ui/react-alert-dialog';
|
||||
import * as Tooltip from '@radix-ui/react-tooltip';
|
||||
import { message } from '@tauri-apps/plugin-dialog';
|
||||
import { useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
import { useNDK } from '@libs/ndk/provider';
|
||||
|
||||
import { LoaderIcon, RepostIcon } from '@shared/icons';
|
||||
|
||||
import { useNostr } from '@utils/hooks/useNostr';
|
||||
import { sendNativeNotification } from '@utils/notification';
|
||||
|
||||
export function NoteRepost({ id, pubkey }: { id: string; pubkey: string }) {
|
||||
const { publish } = useNostr();
|
||||
const { relayUrls } = useNDK();
|
||||
const { ndk, relayUrls } = useNDK();
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
@ -22,18 +18,26 @@ export function NoteRepost({ id, pubkey }: { id: string; pubkey: string }) {
|
||||
|
||||
const submit = async () => {
|
||||
setIsLoading(true);
|
||||
|
||||
const tags = [
|
||||
['e', id, relayUrls[0], 'root'],
|
||||
['p', pubkey],
|
||||
];
|
||||
const event = await publish({ content: '', kind: NDKKind.Repost, tags: tags });
|
||||
if (event) {
|
||||
|
||||
const event = new NDKEvent(ndk);
|
||||
event.content = '';
|
||||
event.kind = NDKKind.Repost;
|
||||
event.tags = tags;
|
||||
|
||||
const publishedRelays = await event.publish();
|
||||
if (publishedRelays) {
|
||||
setOpen(false);
|
||||
setIsRepost(true);
|
||||
await sendNativeNotification('Reposted successfully', 'Lume');
|
||||
|
||||
toast.success(`Broadcasted to ${publishedRelays.size} relays successfully.`);
|
||||
} else {
|
||||
setIsLoading(false);
|
||||
await message('Repost failed, try again later', { title: 'Lume', type: 'error' });
|
||||
toast.error('Repost failed, try again later');
|
||||
}
|
||||
};
|
||||
|
||||
@ -63,11 +67,11 @@ export function NoteRepost({ id, pubkey }: { id: string; pubkey: string }) {
|
||||
</Tooltip.Portal>
|
||||
</Tooltip.Root>
|
||||
<AlertDialog.Portal>
|
||||
<AlertDialog.Overlay className="fixed inset-0 z-50 bg-black/80 backdrop-blur-2xl" />
|
||||
<AlertDialog.Overlay className="fixed inset-0 z-50 bg-black/20 backdrop-blur-sm dark:bg-white/20" />
|
||||
<AlertDialog.Content className="fixed inset-0 z-50 flex min-h-full items-center justify-center">
|
||||
<div className="relative h-min w-full max-w-md rounded-xl bg-neutral-400 dark:bg-neutral-600">
|
||||
<div className="flex flex-col gap-2 border-b border-white/5 px-5 py-4">
|
||||
<AlertDialog.Title className="text-lg font-semibold leading-none text-white">
|
||||
<div className="relative h-min w-full max-w-md rounded-xl bg-white dark:bg-black">
|
||||
<div className="flex flex-col gap-2 border-b border-neutral-100 px-5 py-6 dark:border-neutral-900">
|
||||
<AlertDialog.Title className="text-lg font-semibold leading-none text-neutral-900 dark:text-neutral-100">
|
||||
Confirm repost this post?
|
||||
</AlertDialog.Title>
|
||||
<AlertDialog.Description className="text-sm leading-tight text-neutral-600 dark:text-neutral-400">
|
||||
@ -77,14 +81,14 @@ export function NoteRepost({ id, pubkey }: { id: string; pubkey: string }) {
|
||||
</div>
|
||||
<div className="flex justify-end gap-2 px-3 py-3">
|
||||
<AlertDialog.Cancel asChild>
|
||||
<button className="inline-flex h-9 w-20 items-center justify-center rounded-md text-sm font-medium leading-none text-white outline-none hover:bg-white/10 hover:backdrop-blur-xl">
|
||||
<button className="inline-flex h-9 w-20 items-center justify-center rounded-md text-sm font-medium text-neutral-600 outline-none hover:bg-neutral-200 dark:bg-neutral-800 dark:text-neutral-400">
|
||||
Cancel
|
||||
</button>
|
||||
</AlertDialog.Cancel>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => submit()}
|
||||
className="inline-flex h-9 w-28 items-center justify-center rounded-md bg-white/10 text-sm font-medium leading-none text-white outline-none hover:bg-blue-600"
|
||||
className="inline-flex h-9 w-24 items-center justify-center rounded-md bg-blue-500 text-sm font-medium leading-none text-white outline-none hover:bg-blue-600"
|
||||
>
|
||||
{isLoading ? (
|
||||
<LoaderIcon className="h-4 w-4 animate-spin text-white" />
|
||||
|
@ -1,6 +1,5 @@
|
||||
import { NDKEvent, NDKKind } from '@nostr-dev-kit/ndk';
|
||||
import { nip19 } from 'nostr-tools';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import {
|
||||
ArticleNote,
|
||||
@ -49,24 +48,17 @@ export function ChildNote({ id, root }: { id: string; root?: string }) {
|
||||
<div className="absolute bottom-0 left-[18px] h-[calc(100%-3.4rem)] w-0.5 bg-gradient-to-t from-black/20 to-black/10 dark:from-white/20 dark:to-white/10" />
|
||||
<div className="relative mb-5 flex flex-col">
|
||||
<div className="relative z-10 flex items-start gap-3">
|
||||
<div className="inline-flex h-10 w-10 items-end justify-center rounded-lg bg-black pb-1">
|
||||
<img src="/lume.png" alt="lume" className="h-auto w-1/3" />
|
||||
</div>
|
||||
<h5 className="truncate font-semibold leading-none text-white">
|
||||
Lume <span className="text-green-500">(System)</span>
|
||||
<div className="inline-flex h-10 w-10 shrink-0 items-end justify-center rounded-lg bg-black"></div>
|
||||
<h5 className="truncate font-semibold leading-none text-neutral-900 dark:text-neutral-100">
|
||||
Lume <span className="text-teal-500">(System)</span>
|
||||
</h5>
|
||||
</div>
|
||||
<div className="-mt-4 flex items-start gap-3">
|
||||
<div className="w-10 shrink-0" />
|
||||
<div>
|
||||
<div className="relative z-20 mt-1 flex-1 select-text">
|
||||
<div className="mb-1 select-text rounded-lg bg-white/5 p-1.5 text-sm">
|
||||
Lume cannot find this post with your current relays, but you can view it
|
||||
via njump.me.{' '}
|
||||
<Link to={noteLink} className="text-blue-500">
|
||||
Learn more
|
||||
</Link>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="prose prose-neutral max-w-none select-text whitespace-pre-line break-all leading-normal dark:prose-invert prose-headings:mb-1 prose-headings:mt-3 prose-p:mb-0 prose-p:mt-0 prose-p:last:mb-1 prose-a:font-normal prose-a:text-blue-500 prose-blockquote:mb-1 prose-blockquote:mt-1 prose-blockquote:border-l-[2px] prose-blockquote:border-blue-500 prose-blockquote:pl-2 prose-pre:whitespace-pre-wrap prose-pre:break-words prose-pre:break-all prose-pre:bg-white/10 prose-ol:m-0 prose-ol:mb-1 prose-ul:mb-1 prose-ul:mt-1 prose-img:mb-2 prose-img:mt-3 prose-hr:mx-0 prose-hr:my-2 hover:prose-a:text-blue-500">
|
||||
Lume cannot find this post with your current relay set, but you can view
|
||||
it via njump.me
|
||||
</div>
|
||||
<LinkPreview urls={[noteLink]} />
|
||||
</div>
|
||||
|
@ -41,7 +41,7 @@ export function FileNote(props: { event?: NDKEvent }) {
|
||||
slot="media"
|
||||
src={url}
|
||||
poster={`https://thumbnail.video/api/get?url=${url}&seconds=1`}
|
||||
preload="auto"
|
||||
preload="none"
|
||||
muted
|
||||
crossOrigin=""
|
||||
/>
|
||||
|
@ -2,7 +2,6 @@ import { NDKEvent, NDKKind } from '@nostr-dev-kit/ndk';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { nip19 } from 'nostr-tools';
|
||||
import { memo, useCallback } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
import { useNDK } from '@libs/ndk/provider';
|
||||
@ -113,24 +112,17 @@ export function Repost({
|
||||
>
|
||||
<div className="relative flex flex-col">
|
||||
<div className="relative z-10 flex items-start gap-3">
|
||||
<div className="inline-flex h-11 w-11 items-end justify-center rounded-lg bg-black pb-1">
|
||||
<img src="/lume.png" alt="lume" className="h-auto w-1/3" />
|
||||
</div>
|
||||
<h5 className="truncate font-semibold leading-none text-white">
|
||||
Lume <span className="text-green-500">(System)</span>
|
||||
<div className="inline-flex h-10 w-10 shrink-0 items-end justify-center rounded-lg bg-black"></div>
|
||||
<h5 className="truncate font-semibold leading-none text-neutral-900 dark:text-neutral-100">
|
||||
Lume <span className="text-teal-500">(System)</span>
|
||||
</h5>
|
||||
</div>
|
||||
<div className="-mt-4 flex items-start gap-3">
|
||||
<div className="w-11 shrink-0" />
|
||||
<div>
|
||||
<div className="relative z-20 mt-1 flex-1 select-text">
|
||||
<div className="mb-1 select-text rounded-lg bg-white/5 p-1.5 text-sm">
|
||||
Lume cannot find this post with your current relays, but you can view
|
||||
it via njump.me.{' '}
|
||||
<Link to={noteLink} className="text-blue-500">
|
||||
Learn more
|
||||
</Link>
|
||||
</div>
|
||||
<div className="w-10 shrink-0" />
|
||||
<div className="flex-1">
|
||||
<div className="prose prose-neutral max-w-none select-text whitespace-pre-line break-all leading-normal dark:prose-invert prose-headings:mb-1 prose-headings:mt-3 prose-p:mb-0 prose-p:mt-0 prose-p:last:mb-1 prose-a:font-normal prose-a:text-blue-500 prose-blockquote:mb-1 prose-blockquote:mt-1 prose-blockquote:border-l-[2px] prose-blockquote:border-blue-500 prose-blockquote:pl-2 prose-pre:whitespace-pre-wrap prose-pre:break-words prose-pre:break-all prose-pre:bg-white/10 prose-ol:m-0 prose-ol:mb-1 prose-ul:mb-1 prose-ul:mt-1 prose-img:mb-2 prose-img:mt-3 prose-hr:mx-0 prose-hr:my-2 hover:prose-a:text-blue-500">
|
||||
Lume cannot find this post with your current relay set, but you can view
|
||||
it via njump.me
|
||||
</div>
|
||||
<LinkPreview urls={[noteLink]} />
|
||||
</div>
|
||||
|
@ -23,7 +23,7 @@ export function TextNote(props: { content?: string }) {
|
||||
return (
|
||||
<div>
|
||||
<ReactMarkdown
|
||||
className="prose prose-neutral max-w-none select-text whitespace-pre-line leading-normal dark:prose-invert prose-headings:mb-1 prose-headings:mt-3 prose-p:mb-0 prose-p:mt-0 prose-p:last:mb-1 prose-a:font-normal prose-a:text-blue-500 prose-blockquote:mb-1 prose-blockquote:mt-1 prose-blockquote:border-l-[2px] prose-blockquote:border-blue-500 prose-blockquote:pl-2 prose-pre:whitespace-pre-wrap prose-pre:break-words prose-pre:break-all prose-pre:bg-white/10 prose-ol:m-0 prose-ol:mb-1 prose-ul:mb-1 prose-ul:mt-1 prose-img:mb-2 prose-img:mt-3 prose-hr:mx-0 prose-hr:my-2 hover:prose-a:text-blue-500"
|
||||
className="break-p prose prose-neutral max-w-none select-text whitespace-pre-line leading-normal dark:prose-invert prose-headings:mb-1 prose-headings:mt-3 prose-p:mb-0 prose-p:mt-0 prose-p:last:mb-1 prose-a:font-normal prose-a:text-blue-500 prose-blockquote:mb-1 prose-blockquote:mt-1 prose-blockquote:border-l-[2px] prose-blockquote:border-blue-500 prose-blockquote:pl-2 prose-pre:whitespace-pre-wrap prose-pre:bg-white/10 prose-ol:m-0 prose-ol:mb-1 prose-ul:mb-1 prose-ul:mt-1 prose-img:mb-2 prose-img:mt-3 prose-hr:mx-0 prose-hr:my-2 hover:prose-a:text-blue-500"
|
||||
remarkPlugins={[remarkGfm]}
|
||||
disallowedElements={['h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'pre', 'code']}
|
||||
unwrapDisallowed={true}
|
||||
@ -38,7 +38,7 @@ export function TextNote(props: { content?: string }) {
|
||||
return (
|
||||
<div>
|
||||
<ReactMarkdown
|
||||
className="prose prose-neutral max-w-none select-text whitespace-pre-line leading-normal dark:prose-invert prose-headings:mb-1 prose-headings:mt-3 prose-p:mb-0 prose-p:mt-0 prose-p:last:mb-1 prose-a:font-normal prose-a:text-blue-500 prose-blockquote:mb-1 prose-blockquote:mt-1 prose-blockquote:border-l-[2px] prose-blockquote:border-blue-500 prose-blockquote:pl-2 prose-pre:whitespace-pre-wrap prose-pre:break-words prose-pre:break-all prose-pre:bg-white/10 prose-ol:m-0 prose-ol:mb-1 prose-ul:mb-1 prose-ul:mt-1 prose-img:mb-2 prose-img:mt-3 prose-hr:mx-0 prose-hr:my-2 hover:prose-a:text-blue-500"
|
||||
className="break-p prose prose-neutral max-w-none select-text whitespace-pre-line leading-normal dark:prose-invert prose-headings:mb-1 prose-headings:mt-3 prose-p:mb-0 prose-p:mt-0 prose-p:last:mb-1 prose-a:font-normal prose-a:text-blue-500 prose-blockquote:mb-1 prose-blockquote:mt-1 prose-blockquote:border-l-[2px] prose-blockquote:border-blue-500 prose-blockquote:pl-2 prose-pre:whitespace-pre-wrap prose-pre:bg-white/10 prose-ol:m-0 prose-ol:mb-1 prose-ul:mb-1 prose-ul:mt-1 prose-img:mb-2 prose-img:mt-3 prose-hr:mx-0 prose-hr:my-2 hover:prose-a:text-blue-500"
|
||||
remarkPlugins={[remarkGfm]}
|
||||
components={{
|
||||
a: ({ href }) => {
|
||||
|
@ -52,7 +52,7 @@ export function LinkPreview({ urls }: { urls: string[] }) {
|
||||
</h5>
|
||||
)}
|
||||
{data.description && (
|
||||
<p className="mb-2.5 line-clamp-3 break-words text-sm text-neutral-700 dark:text-neutral-400">
|
||||
<p className="mb-2.5 line-clamp-3 break-all text-sm text-neutral-700 dark:text-neutral-400">
|
||||
{data.description}
|
||||
</p>
|
||||
)}
|
||||
|
@ -18,7 +18,7 @@ export const VideoPreview = memo(function VideoPreview({ urls }: { urls: string[
|
||||
slot="media"
|
||||
src={url}
|
||||
poster={`https://thumbnail.video/api/get?url=${url}&seconds=1`}
|
||||
preload="auto"
|
||||
preload="none"
|
||||
muted
|
||||
/>
|
||||
<MediaControlBar>
|
||||
|
@ -2,8 +2,10 @@ export function NoteSkeleton() {
|
||||
return (
|
||||
<div className="flex h-min flex-col">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="relative h-11 w-11 shrink overflow-hidden rounded-lg bg-neutral-400 dark:bg-neutral-600" />
|
||||
<div className="h-3 w-20 rounded bg-neutral-400 dark:bg-neutral-600" />
|
||||
<div className="relative h-10 w-10 shrink-0 overflow-hidden rounded-lg bg-neutral-400 dark:bg-neutral-600" />
|
||||
<div className="h-6 w-full">
|
||||
<div className="h-3 w-24 animate-pulse rounded bg-neutral-300 dark:bg-neutral-700" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="-mt-5 flex animate-pulse gap-3">
|
||||
<div className="w-10 shrink-0" />
|
||||
|
@ -38,7 +38,7 @@ export function NoteWrapper({
|
||||
children,
|
||||
event.kind === 1 ? { content: event.content } : { event: event }
|
||||
)}
|
||||
<NoteActions id={event.id} pubkey={event.pubkey} />
|
||||
<NoteActions id={event.id} pubkey={event.pubkey} root={root} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -80,7 +80,7 @@ export const User = memo(function User({
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
style={{ contentVisibility: 'auto' }}
|
||||
className="h-6 w-6 rounded"
|
||||
className="h-6 w-6 rounded-md"
|
||||
/>
|
||||
<Avatar.Fallback delayMs={300}>
|
||||
<img
|
||||
@ -97,8 +97,8 @@ export const User = memo(function User({
|
||||
user?.displayName ||
|
||||
displayNpub(pubkey, 16)}
|
||||
</h5>
|
||||
<span className="text-neutral-500 dark:text-neutral-300">·</span>
|
||||
<span className="text-neutral-500 dark:text-neutral-300">{createdAt}</span>
|
||||
<span className="text-neutral-600 dark:text-neutral-400">·</span>
|
||||
<span className="text-neutral-600 dark:text-neutral-400">{createdAt}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
@ -21,7 +21,6 @@ import { EventLoader, WidgetWrapper } from '@shared/widgets';
|
||||
import { WidgetKinds, useWidgets } from '@stores/widgets';
|
||||
|
||||
import { useNostr } from '@utils/hooks/useNostr';
|
||||
import { toRawEvent } from '@utils/rawEvent';
|
||||
import { DBEvent } from '@utils/types';
|
||||
|
||||
export function LocalNetworkWidget() {
|
||||
@ -103,8 +102,7 @@ export function LocalNetworkWidget() {
|
||||
};
|
||||
|
||||
sub(filter, async (event) => {
|
||||
const rawEvent = toRawEvent(event);
|
||||
await db.createEvent(rawEvent);
|
||||
await db.createEvent(event);
|
||||
});
|
||||
}
|
||||
}, [data]);
|
||||
|
@ -1,30 +0,0 @@
|
||||
import { create } from 'zustand';
|
||||
|
||||
interface ComposerState {
|
||||
expand: boolean;
|
||||
open: boolean;
|
||||
reply: { id: string; pubkey: string; root?: string };
|
||||
toggleModal: () => void;
|
||||
toggleExpand: () => void;
|
||||
setReply: (id: string, pubkey: string, root?: string) => void;
|
||||
clearReply: () => void;
|
||||
}
|
||||
|
||||
export const useComposer = create<ComposerState>((set) => ({
|
||||
expand: false,
|
||||
open: false,
|
||||
reply: { id: null, pubkey: null, root: null },
|
||||
toggleModal: () => {
|
||||
set((state) => ({ open: !state.open }));
|
||||
},
|
||||
toggleExpand: () => {
|
||||
set((state) => ({ expand: !state.expand }));
|
||||
},
|
||||
setReply: (id: string, pubkey: string, root?: string) => {
|
||||
set({ reply: { id: id, pubkey: pubkey, root: root } });
|
||||
set({ open: true });
|
||||
},
|
||||
clearReply: () => {
|
||||
set({ reply: { id: null, pubkey: null, root: null } });
|
||||
},
|
||||
}));
|
@ -1,6 +1,7 @@
|
||||
export const FULL_RELAYS = [
|
||||
'wss://relay.damus.io',
|
||||
'wss://nos.lol',
|
||||
'wss://relay.primal.net',
|
||||
'wss://relayable.org',
|
||||
'wss://relay.nostr.band/all',
|
||||
'wss://nostr.mutinywallet.com',
|
||||
];
|
||||
|
@ -1,12 +1,10 @@
|
||||
import { NDKEvent } from '@nostr-dev-kit/ndk';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { AddressPointer } from 'nostr-tools/lib/nip19';
|
||||
import { AddressPointer } from 'nostr-tools/lib/types/nip19';
|
||||
|
||||
import { useNDK } from '@libs/ndk/provider';
|
||||
import { useStorage } from '@libs/storage/provider';
|
||||
|
||||
import { toRawEvent } from '@utils/rawEvent';
|
||||
|
||||
export function useEvent(
|
||||
id: undefined | string,
|
||||
naddr?: undefined | AddressPointer,
|
||||
@ -43,8 +41,7 @@ export function useEvent(
|
||||
const event = await ndk.fetchEvent(id);
|
||||
if (!event) return Promise.reject(new Error('event not found'));
|
||||
|
||||
const rawEvent = toRawEvent(event);
|
||||
await db.createEvent(rawEvent);
|
||||
await db.createEvent(event);
|
||||
|
||||
return event;
|
||||
},
|
||||
|
@ -1,12 +0,0 @@
|
||||
import { NDKEvent } from '@nostr-dev-kit/ndk';
|
||||
|
||||
export function toRawEvent(event: NDKEvent) {
|
||||
delete event.ndk;
|
||||
delete event.decrypt;
|
||||
delete event.encrypt;
|
||||
delete event.encode;
|
||||
delete event.isParamReplaceable;
|
||||
delete event.isReplaceable;
|
||||
delete event.repost;
|
||||
return event;
|
||||
}
|
@ -1,4 +1,4 @@
|
||||
import { NDKTag } from '@nostr-dev-kit/ndk';
|
||||
import { NDKEvent, NDKTag, NostrEvent } from '@nostr-dev-kit/ndk';
|
||||
|
||||
// convert array to NIP-02 tag list
|
||||
export function arrayToNIP02(arr: string[]) {
|
||||
@ -30,3 +30,15 @@ export function getMultipleRandom(arr: string[], num: number) {
|
||||
const shuffled = [...arr].sort(() => 0.5 - Math.random());
|
||||
return shuffled.slice(0, num);
|
||||
}
|
||||
|
||||
export function rawEvent(event: NDKEvent) {
|
||||
return {
|
||||
created_at: event.created_at,
|
||||
content: event.content,
|
||||
tags: event.tags,
|
||||
kind: event.kind,
|
||||
pubkey: event.pubkey,
|
||||
id: event.id,
|
||||
sig: event.sig,
|
||||
} as NostrEvent;
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user