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-upload": "2.0.0-alpha.1",
"@tauri-apps/plugin-window": "2.0.0-alpha.1", "@tauri-apps/plugin-window": "2.0.0-alpha.1",
"@tiptap/extension-character-count": "^2.1.12", "@tiptap/extension-character-count": "^2.1.12",
"@tiptap/extension-document": "^2.1.12",
"@tiptap/extension-image": "^2.1.12", "@tiptap/extension-image": "^2.1.12",
"@tiptap/extension-mention": "^2.1.12", "@tiptap/extension-mention": "^2.1.12",
"@tiptap/extension-paragraph": "^2.1.12",
"@tiptap/extension-placeholder": "^2.1.12", "@tiptap/extension-placeholder": "^2.1.12",
"@tiptap/extension-text": "^2.1.12",
"@tiptap/pm": "^2.1.12", "@tiptap/pm": "^2.1.12",
"@tiptap/react": "^2.1.12", "@tiptap/react": "^2.1.12",
"@tiptap/starter-kit": "^2.1.12", "@tiptap/starter-kit": "^2.1.12",

View File

@ -101,15 +101,24 @@ dependencies:
'@tiptap/extension-character-count': '@tiptap/extension-character-count':
specifier: ^2.1.12 specifier: ^2.1.12
version: 2.1.12(@tiptap/core@2.1.12)(@tiptap/pm@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': '@tiptap/extension-image':
specifier: ^2.1.12 specifier: ^2.1.12
version: 2.1.12(@tiptap/core@2.1.12) version: 2.1.12(@tiptap/core@2.1.12)
'@tiptap/extension-mention': '@tiptap/extension-mention':
specifier: ^2.1.12 specifier: ^2.1.12
version: 2.1.12(@tiptap/core@2.1.12)(@tiptap/pm@2.1.12)(@tiptap/suggestion@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': '@tiptap/extension-placeholder':
specifier: ^2.1.12 specifier: ^2.1.12
version: 2.1.12(@tiptap/core@2.1.12)(@tiptap/pm@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': '@tiptap/pm':
specifier: ^2.1.12 specifier: ^2.1.12
version: 2.1.12 version: 2.1.12

View File

@ -4,6 +4,14 @@
@tailwind components; @tailwind components;
@tailwind utilities; @tailwind utilities;
@layer utilities {
.break-p {
word-break: break-word;
word-wrap: break-word;
overflow-wrap: break-word;
}
}
html { html {
font-size: 14px; font-size: 14px;
} }

View File

@ -6,6 +6,7 @@ import { OnboardingScreen } from '@app/auth/onboarding';
import { ChatsScreen } from '@app/chats'; import { ChatsScreen } from '@app/chats';
import { ErrorScreen } from '@app/error'; import { ErrorScreen } from '@app/error';
import { ExploreScreen } from '@app/explore'; import { ExploreScreen } from '@app/explore';
import { NewScreen } from '@app/new';
import { useStorage } from '@libs/storage/provider'; 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', path: '/notes',
element: <NoteLayout />, element: <NoteLayout />,
errorElement: <ErrorScreen />, errorElement: <ErrorScreen />,
children: [ children: [
{
path: 'new',
async lazy() {
const { NewNoteScreen } = await import('@app/notes/new');
return { Component: NewNoteScreen };
},
},
{ {
path: 'text/:id', path: 'text/:id',
async lazy() { async lazy() {

View File

@ -1,11 +1,12 @@
import { NDKEvent, NDKKind } from '@nostr-dev-kit/ndk';
import { nip04 } from 'nostr-tools'; import { nip04 } from 'nostr-tools';
import { useCallback, useState } from 'react'; import { useCallback, useState } from 'react';
import { MediaUploader } from '@app/chats/components/mediaUploader'; 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({ export function ChatForm({
receiverPubkey, receiverPubkey,
@ -15,7 +16,7 @@ export function ChatForm({
userPubkey: string; userPubkey: string;
userPrivkey: string; userPrivkey: string;
}) { }) {
const { publish } = useNostr(); const { ndk } = useNDK();
const [value, setValue] = useState(''); const [value, setValue] = useState('');
const encryptMessage = useCallback(async () => { const encryptMessage = useCallback(async () => {
@ -26,8 +27,12 @@ export function ChatForm({
const message = await encryptMessage(); const message = await encryptMessage();
const tags = [['p', receiverPubkey]]; const tags = [['p', receiverPubkey]];
// publish message const event = new NDKEvent(ndk);
await publish({ content: message, kind: 4, tags }); event.content = message;
event.kind = NDKKind.EncryptedDirectMessage;
event.tags = tags;
await event.publish();
// reset state // reset state
setValue(''); 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 { Editor } from '@tiptap/react';
import { nip19 } from 'nostr-tools'; import { nip19 } from 'nostr-tools';
import { MentionPopupItem } from '@app/new/components';
import { useStorage } from '@libs/storage/provider'; import { useStorage } from '@libs/storage/provider';
import { MentionItem } from '@shared/composer';
import { MentionIcon } from '@shared/icons'; import { MentionIcon } from '@shared/icons';
export function MentionPopup({ editor }: { editor: Editor }) { export function MentionPopup({ editor }: { editor: Editor }) {
@ -34,7 +35,7 @@ export function MentionPopup({ editor }: { editor: Editor }) {
{db.account.follows.length > 0 ? ( {db.account.follows.length > 0 ? (
db.account.follows.map((item) => ( db.account.follows.map((item) => (
<button key={item} type="button" onClick={() => insertMention(item)}> <button key={item} type="button" onClick={() => insertMention(item)}>
<MentionItem pubkey={item} /> <MentionPopupItem pubkey={item} />
</button> </button>
)) ))
) : ( ) : (

View File

@ -3,7 +3,7 @@ import { Image } from '@shared/image';
import { useProfile } from '@utils/hooks/useProfile'; import { useProfile } from '@utils/hooks/useProfile';
import { displayNpub } from '@utils/shortenKey'; 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); const { status, user } = useProfile(pubkey, embed);
if (status === 'loading') { if (status === 'loading') {

View File

@ -8,12 +8,12 @@ import { useNDK } from '@libs/ndk/provider';
import { LoaderIcon } from '@shared/icons'; import { LoaderIcon } from '@shared/icons';
export function FileEditor() { export function NewFileScreen() {
const { ndk } = useNDK(); const { ndk } = useNDK();
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [isPublish, setIsPublish] = useState(false); const [isPublish, setIsPublish] = useState(false);
const [metadata, setMetadata] = useState(null); const [metadata, setMetadata] = useState<string[][] | null>(null);
const [caption, setCaption] = useState(''); const [caption, setCaption] = useState('');
const uploadFile = async () => { const uploadFile = async () => {
@ -105,11 +105,11 @@ export function FileEditor() {
return ( return (
<div className="h-full"> <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 <button
type="button" type="button"
onClick={uploadFile} 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 ? ( {loading ? (
<LoaderIcon className="h-5 w-5 animate-spin text-neutral-900 dark:text-neutral-100" /> <LoaderIcon className="h-5 w-5 animate-spin text-neutral-900 dark:text-neutral-100" />
@ -127,13 +127,24 @@ export function FileEditor() {
<img <img
src={metadata[0][1]} src={metadata[0][1]}
alt={metadata[1][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> </div>
)} )}
</button> </button>
<div className="mx-auto w-full max-w-sm"> {metadata ? (
<div className="inline-flex w-full items-center gap-2"> <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 <input
name="caption" name="caption"
type="text" type="text"
@ -144,18 +155,19 @@ export function FileEditor() {
autoCorrect="off" autoCorrect="off"
autoCapitalize="off" autoCapitalize="off"
placeholder="Caption (Optional)..." 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 <button
type="button" type="button"
onClick={submit} onClick={submit}
disabled={!metadata} 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'} {isPublish ? <LoaderIcon className="h-4 w-4 animate-spin" /> : 'Share'}
</button> </button>
</div> </div>
</div> </div>
) : null}
</div> </div>
</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 { EditorContent, useEditor } from '@tiptap/react';
import StarterKit from '@tiptap/starter-kit'; import StarterKit from '@tiptap/starter-kit';
import { convert } from 'html-to-text'; 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 { toast } from 'sonner';
import { MediaUploader, MentionPopup } from '@app/new/components';
import { useNDK } from '@libs/ndk/provider'; import { useNDK } from '@libs/ndk/provider';
import { MediaUploader, MentionPopup } from '@shared/composer'; import { CancelIcon, LoaderIcon } from '@shared/icons';
import { 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 [loading, setLoading] = useState(false);
const [searchParams, setSearchParams] = useSearchParams();
const editor = useEditor({ const editor = useEditor({
extensions: [ extensions: [
@ -46,6 +51,12 @@ export function PostEditor() {
try { try {
setLoading(true); setLoading(true);
const reply = {
id: searchParams.get('id'),
root: searchParams.get('root'),
pubkey: searchParams.get('pubkey'),
};
// get plaintext content // get plaintext content
const html = editor.getHTML(); const html = editor.getHTML();
const serializedContent = convert(html, { const serializedContent = convert(html, {
@ -56,12 +67,29 @@ export function PostEditor() {
}); });
// define tags // 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 // add hashtag to tags if present
const hashtags = serializedContent const hashtags = serializedContent
.split(/\s/gm) .split(/\s/gm)
.filter((s: string) => s.startsWith('#')); .filter((s: string) => s.startsWith('#'));
hashtags?.forEach((tag: string) => { hashtags?.forEach((tag: string) => {
tags.push(['t', tag.replace('#', '')]); tags.push(['t', tag.replace('#', '')]);
}); });
@ -78,6 +106,7 @@ export function PostEditor() {
// update state // update state
setLoading(false); setLoading(false);
// reset editor // reset editor
setSearchParams({});
editor.commands.clearContent(); editor.commands.clearContent();
localStorage.setItem('editor-post', '{}'); localStorage.setItem('editor-post', '{}');
} }
@ -87,8 +116,13 @@ export function PostEditor() {
} }
}; };
useEffect(() => {
if (editor) editor.commands.focus('end');
}, [editor]);
return ( return (
<div className="flex h-full flex-col justify-between"> <div className="flex h-full flex-col justify-between">
<div>
<EditorContent <EditorContent
editor={editor} editor={editor}
spellCheck="false" spellCheck="false"
@ -96,6 +130,19 @@ export function PostEditor() {
autoCorrect="off" autoCorrect="off"
autoCapitalize="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"> <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"> <span className="text-sm font-medium tabular-nums text-neutral-600 dark:text-neutral-400">
{editor?.storage?.characterCount.characters()} {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> <Dialog.Trigger asChild>
<button <button
type="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 Connect
</button> </button>

View File

@ -86,7 +86,7 @@ export function NWCOther() {
<Dialog.Trigger asChild> <Dialog.Trigger asChild>
<button <button
type="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 Connect
</button> </button>

View File

@ -53,7 +53,7 @@ export function RelayList() {
</div> </div>
</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"> <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"> <h3 className="font-semibold text-neutral-950 dark:text-neutral-50">
All relays used by your follows All relays used by your follows
@ -103,7 +103,6 @@ export function RelayList() {
</div> </div>
</div> </div>
))} ))}
<div className="h-16" />
</VList> </VList>
)} )}
</div> </div>

View File

@ -5,6 +5,7 @@ import Database from '@tauri-apps/plugin-sql';
import { FULL_RELAYS } from '@stores/constants'; import { FULL_RELAYS } from '@stores/constants';
import { rawEvent } from '@utils/transform';
import { Account, DBEvent, Relays, Widget } from '@utils/types'; import { Account, DBEvent, Relays, Widget } from '@utils/types';
export class LumeStorage { export class LumeStorage {
@ -140,6 +141,8 @@ export class LumeStorage {
} }
public async createEvent(event: NDKEvent) { public async createEvent(event: NDKEvent) {
const rawNostrEvent = rawEvent(event);
let root: string; let root: string;
let reply: string; let reply: string;
@ -155,7 +158,7 @@ export class LumeStorage {
[ [
event.id, event.id,
this.account.id, this.account.id,
JSON.stringify(event), JSON.stringify(rawNostrEvent),
event.pubkey, event.pubkey,
event.kind, event.kind,
root, 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 './home';
export * from './chats'; export * from './chats';
export * from './community'; 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>
<div className="flex shrink-0 flex-col gap-3 p-1"> <div className="flex shrink-0 flex-col gap-3 p-1">
<Link <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" 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" /> <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 * as Popover from '@radix-ui/react-popover';
import { useState } from 'react'; 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 = [ const REACTIONS = [
{ {
@ -30,11 +30,11 @@ const REACTIONS = [
]; ];
export function NoteReaction({ id, pubkey }: { id: string; pubkey: string }) { export function NoteReaction({ id, pubkey }: { id: string; pubkey: string }) {
const { ndk } = useNDK();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [reaction, setReaction] = useState<string | null>(null); const [reaction, setReaction] = useState<string | null>(null);
const { publish } = useNostr();
const getReactionImage = (content: string) => { const getReactionImage = (content: string) => {
const reaction: { img: string } = REACTIONS.find((el) => el.content === content); const reaction: { img: string } = REACTIONS.find((el) => el.content === content);
return reaction.img; return reaction.img;
@ -43,16 +43,16 @@ export function NoteReaction({ id, pubkey }: { id: string; pubkey: string }) {
const react = async (content: string) => { const react = async (content: string) => {
setReaction(content); setReaction(content);
const event = await publish({ const event = new NDKEvent(ndk);
content: content, event.content = content;
kind: NDKKind.Reaction, event.kind = NDKKind.Reaction;
tags: [ event.tags = [
['e', id], ['e', id],
['p', pubkey], ['p', pubkey],
], ];
});
if (event) { const publishedRelays = await event.publish();
if (publishedRelays) {
setOpen(false); setOpen(false);
} }
}; };

View File

@ -1,9 +1,8 @@
import * as Tooltip from '@radix-ui/react-tooltip'; import * as Tooltip from '@radix-ui/react-tooltip';
import { createSearchParams, useNavigate } from 'react-router-dom';
import { ReplyIcon } from '@shared/icons'; import { ReplyIcon } from '@shared/icons';
import { useComposer } from '@stores/composer';
export function NoteReply({ export function NoteReply({
id, id,
pubkey, pubkey,
@ -13,14 +12,23 @@ export function NoteReply({
pubkey: string; pubkey: string;
root?: string; root?: string;
}) { }) {
const setReply = useComposer((state) => state.setReply); const navigate = useNavigate();
return ( return (
<Tooltip.Root delayDuration={150}> <Tooltip.Root delayDuration={150}>
<Tooltip.Trigger asChild> <Tooltip.Trigger asChild>
<button <button
type="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" 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" /> <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 AlertDialog from '@radix-ui/react-alert-dialog';
import * as Tooltip from '@radix-ui/react-tooltip'; import * as Tooltip from '@radix-ui/react-tooltip';
import { message } from '@tauri-apps/plugin-dialog';
import { useState } from 'react'; import { useState } from 'react';
import { toast } from 'sonner';
import { twMerge } from 'tailwind-merge'; import { twMerge } from 'tailwind-merge';
import { useNDK } from '@libs/ndk/provider'; import { useNDK } from '@libs/ndk/provider';
import { LoaderIcon, RepostIcon } from '@shared/icons'; 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 }) { export function NoteRepost({ id, pubkey }: { id: string; pubkey: string }) {
const { publish } = useNostr(); const { ndk, relayUrls } = useNDK();
const { relayUrls } = useNDK();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
@ -22,18 +18,26 @@ export function NoteRepost({ id, pubkey }: { id: string; pubkey: string }) {
const submit = async () => { const submit = async () => {
setIsLoading(true); setIsLoading(true);
const tags = [ const tags = [
['e', id, relayUrls[0], 'root'], ['e', id, relayUrls[0], 'root'],
['p', pubkey], ['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); setOpen(false);
setIsRepost(true); setIsRepost(true);
await sendNativeNotification('Reposted successfully', 'Lume');
toast.success(`Broadcasted to ${publishedRelays.size} relays successfully.`);
} else { } else {
setIsLoading(false); 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.Portal>
</Tooltip.Root> </Tooltip.Root>
<AlertDialog.Portal> <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"> <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="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-white/5 px-5 py-4"> <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-white"> <AlertDialog.Title className="text-lg font-semibold leading-none text-neutral-900 dark:text-neutral-100">
Confirm repost this post? Confirm repost this post?
</AlertDialog.Title> </AlertDialog.Title>
<AlertDialog.Description className="text-sm leading-tight text-neutral-600 dark:text-neutral-400"> <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>
<div className="flex justify-end gap-2 px-3 py-3"> <div className="flex justify-end gap-2 px-3 py-3">
<AlertDialog.Cancel asChild> <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 Cancel
</button> </button>
</AlertDialog.Cancel> </AlertDialog.Cancel>
<button <button
type="button" type="button"
onClick={() => submit()} 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 ? ( {isLoading ? (
<LoaderIcon className="h-4 w-4 animate-spin text-white" /> <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 { NDKEvent, NDKKind } from '@nostr-dev-kit/ndk';
import { nip19 } from 'nostr-tools'; import { nip19 } from 'nostr-tools';
import { Link } from 'react-router-dom';
import { import {
ArticleNote, 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="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 mb-5 flex flex-col">
<div className="relative z-10 flex items-start gap-3"> <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"> <div className="inline-flex h-10 w-10 shrink-0 items-end justify-center rounded-lg bg-black"></div>
<img src="/lume.png" alt="lume" className="h-auto w-1/3" /> <h5 className="truncate font-semibold leading-none text-neutral-900 dark:text-neutral-100">
</div> Lume <span className="text-teal-500">(System)</span>
<h5 className="truncate font-semibold leading-none text-white">
Lume <span className="text-green-500">(System)</span>
</h5> </h5>
</div> </div>
<div className="-mt-4 flex items-start gap-3"> <div className="-mt-4 flex items-start gap-3">
<div className="w-10 shrink-0" /> <div className="w-10 shrink-0" />
<div> <div className="flex-1">
<div className="relative z-20 mt-1 flex-1 select-text"> <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">
<div className="mb-1 select-text rounded-lg bg-white/5 p-1.5 text-sm"> Lume cannot find this post with your current relay set, but you can view
Lume cannot find this post with your current relays, but you can view it it via njump.me
via njump.me.{' '}
<Link to={noteLink} className="text-blue-500">
Learn more
</Link>
</div>
</div> </div>
<LinkPreview urls={[noteLink]} /> <LinkPreview urls={[noteLink]} />
</div> </div>

View File

@ -41,7 +41,7 @@ export function FileNote(props: { event?: NDKEvent }) {
slot="media" slot="media"
src={url} src={url}
poster={`https://thumbnail.video/api/get?url=${url}&seconds=1`} poster={`https://thumbnail.video/api/get?url=${url}&seconds=1`}
preload="auto" preload="none"
muted muted
crossOrigin="" crossOrigin=""
/> />

View File

@ -2,7 +2,6 @@ import { NDKEvent, NDKKind } from '@nostr-dev-kit/ndk';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { nip19 } from 'nostr-tools'; import { nip19 } from 'nostr-tools';
import { memo, useCallback } from 'react'; import { memo, useCallback } from 'react';
import { Link } from 'react-router-dom';
import { twMerge } from 'tailwind-merge'; import { twMerge } from 'tailwind-merge';
import { useNDK } from '@libs/ndk/provider'; import { useNDK } from '@libs/ndk/provider';
@ -113,24 +112,17 @@ export function Repost({
> >
<div className="relative flex flex-col"> <div className="relative flex flex-col">
<div className="relative z-10 flex items-start gap-3"> <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"> <div className="inline-flex h-10 w-10 shrink-0 items-end justify-center rounded-lg bg-black"></div>
<img src="/lume.png" alt="lume" className="h-auto w-1/3" /> <h5 className="truncate font-semibold leading-none text-neutral-900 dark:text-neutral-100">
</div> Lume <span className="text-teal-500">(System)</span>
<h5 className="truncate font-semibold leading-none text-white">
Lume <span className="text-green-500">(System)</span>
</h5> </h5>
</div> </div>
<div className="-mt-4 flex items-start gap-3"> <div className="-mt-4 flex items-start gap-3">
<div className="w-11 shrink-0" /> <div className="w-10 shrink-0" />
<div> <div className="flex-1">
<div className="relative z-20 mt-1 flex-1 select-text"> <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">
<div className="mb-1 select-text rounded-lg bg-white/5 p-1.5 text-sm"> Lume cannot find this post with your current relay set, but you can view
Lume cannot find this post with your current relays, but you can view it via njump.me
it via njump.me.{' '}
<Link to={noteLink} className="text-blue-500">
Learn more
</Link>
</div>
</div> </div>
<LinkPreview urls={[noteLink]} /> <LinkPreview urls={[noteLink]} />
</div> </div>

View File

@ -23,7 +23,7 @@ export function TextNote(props: { content?: string }) {
return ( return (
<div> <div>
<ReactMarkdown <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]} remarkPlugins={[remarkGfm]}
disallowedElements={['h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'pre', 'code']} disallowedElements={['h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'pre', 'code']}
unwrapDisallowed={true} unwrapDisallowed={true}
@ -38,7 +38,7 @@ export function TextNote(props: { content?: string }) {
return ( return (
<div> <div>
<ReactMarkdown <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]} remarkPlugins={[remarkGfm]}
components={{ components={{
a: ({ href }) => { a: ({ href }) => {

View File

@ -52,7 +52,7 @@ export function LinkPreview({ urls }: { urls: string[] }) {
</h5> </h5>
)} )}
{data.description && ( {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} {data.description}
</p> </p>
)} )}

View File

@ -18,7 +18,7 @@ export const VideoPreview = memo(function VideoPreview({ urls }: { urls: string[
slot="media" slot="media"
src={url} src={url}
poster={`https://thumbnail.video/api/get?url=${url}&seconds=1`} poster={`https://thumbnail.video/api/get?url=${url}&seconds=1`}
preload="auto" preload="none"
muted muted
/> />
<MediaControlBar> <MediaControlBar>

View File

@ -2,8 +2,10 @@ export function NoteSkeleton() {
return ( return (
<div className="flex h-min flex-col"> <div className="flex h-min flex-col">
<div className="flex items-start gap-3"> <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="relative h-10 w-10 shrink-0 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="h-6 w-full">
<div className="h-3 w-24 animate-pulse rounded bg-neutral-300 dark:bg-neutral-700" />
</div>
</div> </div>
<div className="-mt-5 flex animate-pulse gap-3"> <div className="-mt-5 flex animate-pulse gap-3">
<div className="w-10 shrink-0" /> <div className="w-10 shrink-0" />

View File

@ -38,7 +38,7 @@ export function NoteWrapper({
children, children,
event.kind === 1 ? { content: event.content } : { event: event } 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> </div>
</div> </div>

View File

@ -80,7 +80,7 @@ export const User = memo(function User({
loading="lazy" loading="lazy"
decoding="async" decoding="async"
style={{ contentVisibility: 'auto' }} style={{ contentVisibility: 'auto' }}
className="h-6 w-6 rounded" className="h-6 w-6 rounded-md"
/> />
<Avatar.Fallback delayMs={300}> <Avatar.Fallback delayMs={300}>
<img <img
@ -97,8 +97,8 @@ export const User = memo(function User({
user?.displayName || user?.displayName ||
displayNpub(pubkey, 16)} displayNpub(pubkey, 16)}
</h5> </h5>
<span className="text-neutral-500 dark:text-neutral-300">·</span> <span className="text-neutral-600 dark:text-neutral-400">·</span>
<span className="text-neutral-500 dark:text-neutral-300">{createdAt}</span> <span className="text-neutral-600 dark:text-neutral-400">{createdAt}</span>
</div> </div>
</div> </div>
); );

View File

@ -21,7 +21,6 @@ import { EventLoader, WidgetWrapper } from '@shared/widgets';
import { WidgetKinds, useWidgets } from '@stores/widgets'; import { WidgetKinds, useWidgets } from '@stores/widgets';
import { useNostr } from '@utils/hooks/useNostr'; import { useNostr } from '@utils/hooks/useNostr';
import { toRawEvent } from '@utils/rawEvent';
import { DBEvent } from '@utils/types'; import { DBEvent } from '@utils/types';
export function LocalNetworkWidget() { export function LocalNetworkWidget() {
@ -103,8 +102,7 @@ export function LocalNetworkWidget() {
}; };
sub(filter, async (event) => { sub(filter, async (event) => {
const rawEvent = toRawEvent(event); await db.createEvent(event);
await db.createEvent(rawEvent);
}); });
} }
}, [data]); }, [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 = [ export const FULL_RELAYS = [
'wss://relay.damus.io', 'wss://relay.damus.io',
'wss://nos.lol', 'wss://relay.primal.net',
'wss://relayable.org',
'wss://relay.nostr.band/all', 'wss://relay.nostr.band/all',
'wss://nostr.mutinywallet.com', 'wss://nostr.mutinywallet.com',
]; ];

View File

@ -1,12 +1,10 @@
import { NDKEvent } from '@nostr-dev-kit/ndk'; import { NDKEvent } from '@nostr-dev-kit/ndk';
import { useQuery } from '@tanstack/react-query'; 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 { useNDK } from '@libs/ndk/provider';
import { useStorage } from '@libs/storage/provider'; import { useStorage } from '@libs/storage/provider';
import { toRawEvent } from '@utils/rawEvent';
export function useEvent( export function useEvent(
id: undefined | string, id: undefined | string,
naddr?: undefined | AddressPointer, naddr?: undefined | AddressPointer,
@ -43,8 +41,7 @@ export function useEvent(
const event = await ndk.fetchEvent(id); const event = await ndk.fetchEvent(id);
if (!event) return Promise.reject(new Error('event not found')); if (!event) return Promise.reject(new Error('event not found'));
const rawEvent = toRawEvent(event); await db.createEvent(event);
await db.createEvent(rawEvent);
return 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 // convert array to NIP-02 tag list
export function arrayToNIP02(arr: string[]) { export function arrayToNIP02(arr: string[]) {
@ -30,3 +30,15 @@ export function getMultipleRandom(arr: string[], num: number) {
const shuffled = [...arr].sort(() => 0.5 - Math.random()); const shuffled = [...arr].sort(() => 0.5 - Math.random());
return shuffled.slice(0, num); 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;
}