This commit is contained in:
reya 2023-10-24 13:11:10 +07:00
parent b1a44f2cbf
commit 854a47f266
52 changed files with 815 additions and 798 deletions

View File

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

View File

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

View File

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

View File

@ -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() {

View File

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

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

View File

@ -0,0 +1,4 @@
export * from './articleCoverUploader';
export * from './mediaUploader';
export * from './mentionPopup';
export * from './mentionPopupItem';

View File

@ -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>
))
) : (

View File

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

View File

@ -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,35 +127,47 @@ 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">
<input
name="caption"
type="text"
value={caption}
onChange={(e) => setCaption(e.target.value)}
spellCheck={false}
autoComplete="off"
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"
/>
<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"
>
{isPublish ? <LoaderIcon className="h-4 w-4 animate-spin" /> : 'Share'}
</button>
{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"
value={caption}
onChange={(e) => setCaption(e.target.value)}
spellCheck={false}
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
placeholder="Caption (Optional)..."
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-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>
</div>
) : null}
</div>
</div>
);

77
src/app/new/index.tsx Normal file
View 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>
);
}

View File

@ -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,15 +116,33 @@ export function PostEditor() {
}
};
useEffect(() => {
if (editor) editor.commands.focus('end');
}, [editor]);
return (
<div className="flex h-full flex-col justify-between">
<EditorContent
editor={editor}
spellCheck="false"
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
/>
<div>
<EditorContent
editor={editor}
spellCheck="false"
autoComplete="off"
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()}

View File

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

View File

@ -1,3 +0,0 @@
export * from './editor/post';
export * from './editor/article';
export * from './editor/file';

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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();
},
};
},
};

View File

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

View File

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

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

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

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

View File

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

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

View File

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

View File

@ -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: [
['e', id],
['p', pubkey],
],
});
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);
}
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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 }) => {

View File

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

View File

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

View File

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

View File

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

View File

@ -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
@ -91,14 +91,14 @@ export const User = memo(function User({
</Avatar.Fallback>
</Avatar.Root>
<div className="flex flex-1 items-baseline gap-2">
<h5 className="max-w-[10rem] truncate font-semibold text-neutral-900 dark:text-neutral-100">
<h5 className="max-w-[10rem] truncate font-semibold text-neutral-900 dark:text-neutral-100">
{user?.name ||
user?.display_name ||
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>
);

View File

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

View File

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

View File

@ -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',
];

View File

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

View File

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

View File

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