mirror of
https://github.com/lumehq/lume.git
synced 2025-03-18 05:41:53 +01:00
refactor: everything
This commit is contained in:
parent
9591d8626d
commit
a6da07cd3f
@ -84,6 +84,7 @@
|
||||
"sonner": "^1.2.4",
|
||||
"tippy.js": "^6.3.7",
|
||||
"tiptap-markdown": "^0.8.8",
|
||||
"use-context-selector": "^1.4.1",
|
||||
"use-react-workers": "^0.3.0",
|
||||
"virtua": "^0.18.0",
|
||||
"zustand": "^4.4.7"
|
||||
|
21
pnpm-lock.yaml
generated
21
pnpm-lock.yaml
generated
@ -203,6 +203,9 @@ dependencies:
|
||||
tiptap-markdown:
|
||||
specifier: ^0.8.8
|
||||
version: 0.8.8(@tiptap/core@2.1.13)
|
||||
use-context-selector:
|
||||
specifier: ^1.4.1
|
||||
version: 1.4.1(react-dom@18.2.0)(react@18.2.0)(scheduler@0.23.0)
|
||||
use-react-workers:
|
||||
specifier: ^0.3.0
|
||||
version: 0.3.0(react@18.2.0)
|
||||
@ -5855,6 +5858,24 @@ packages:
|
||||
tslib: 2.6.2
|
||||
dev: false
|
||||
|
||||
/use-context-selector@1.4.1(react-dom@18.2.0)(react@18.2.0)(scheduler@0.23.0):
|
||||
resolution: {integrity: sha512-Io2ArvcRO+6MWIhkdfMFt+WKQX+Vb++W8DS2l03z/Vw/rz3BclKpM0ynr4LYGyU85Eke+Yx5oIhTY++QR0ZDoA==}
|
||||
peerDependencies:
|
||||
react: '>=16.8.0'
|
||||
react-dom: '*'
|
||||
react-native: '*'
|
||||
scheduler: '>=0.19.0'
|
||||
peerDependenciesMeta:
|
||||
react-dom:
|
||||
optional: true
|
||||
react-native:
|
||||
optional: true
|
||||
dependencies:
|
||||
react: 18.2.0
|
||||
react-dom: 18.2.0(react@18.2.0)
|
||||
scheduler: 0.23.0
|
||||
dev: false
|
||||
|
||||
/use-react-workers@0.3.0(react@18.2.0):
|
||||
resolution: {integrity: sha512-CQv/b5lnccR5G1HzrCFbkyeCcKD+TEYFm20veNd+huNSRBM0OXxdvcxAU7vUp3rj8/bHx7WE/rYvCHRyTfJOpQ==}
|
||||
peerDependencies:
|
||||
|
12
src/app.tsx
12
src/app.tsx
@ -1,7 +1,7 @@
|
||||
import { fetch } from '@tauri-apps/plugin-http';
|
||||
import { RouterProvider, createBrowserRouter, defer, redirect } from 'react-router-dom';
|
||||
import { ErrorScreen } from '@app/error';
|
||||
import { useArk } from '@libs/ark';
|
||||
import { useStorage } from '@libs/ark';
|
||||
import { LoaderIcon } from '@shared/icons';
|
||||
import { AppLayout } from '@shared/layouts/app';
|
||||
import { AuthLayout } from '@shared/layouts/auth';
|
||||
@ -10,18 +10,18 @@ import { HomeLayout } from '@shared/layouts/home';
|
||||
import { SettingsLayout } from '@shared/layouts/settings';
|
||||
|
||||
export default function App() {
|
||||
const ark = useArk();
|
||||
const storage = useStorage();
|
||||
|
||||
const router = createBrowserRouter([
|
||||
{
|
||||
element: <AppLayout platform={ark.platform} />,
|
||||
element: <AppLayout platform={storage.platform} />,
|
||||
children: [
|
||||
{
|
||||
path: '/',
|
||||
element: <HomeLayout />,
|
||||
errorElement: <ErrorScreen />,
|
||||
loader: async () => {
|
||||
if (!ark.account) return redirect('auth/welcome');
|
||||
if (!storage.account) return redirect('auth/welcome');
|
||||
return null;
|
||||
},
|
||||
children: [
|
||||
@ -168,7 +168,7 @@ export default function App() {
|
||||
{
|
||||
index: true,
|
||||
loader: () => {
|
||||
const depot = ark.checkDepot();
|
||||
const depot = storage.checkDepot();
|
||||
if (!depot) return redirect('/depot/onboarding/');
|
||||
return null;
|
||||
},
|
||||
@ -190,7 +190,7 @@ export default function App() {
|
||||
},
|
||||
{
|
||||
path: 'auth',
|
||||
element: <AuthLayout platform={ark.platform} />,
|
||||
element: <AuthLayout platform={storage.platform} />,
|
||||
errorElement: <ErrorScreen />,
|
||||
children: [
|
||||
{
|
||||
|
@ -1,4 +1,3 @@
|
||||
import { NDKKind } from '@nostr-dev-kit/ndk';
|
||||
import * as Accordion from '@radix-ui/react-accordion';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { nip19 } from 'nostr-tools';
|
||||
@ -35,6 +34,8 @@ const LUME_USERS = ['npub1zfss807aer0j26mwp2la0ume0jqde3823rmu97ra6sgyyg956e0s6x
|
||||
|
||||
export function FollowScreen() {
|
||||
const ark = useArk();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const { status, data } = useQuery({
|
||||
queryKey: ['trending-profiles-widget'],
|
||||
queryFn: async () => {
|
||||
@ -49,8 +50,6 @@ export function FollowScreen() {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [follows, setFollows] = useState<string[]>([]);
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
// toggle follow state
|
||||
const toggleFollow = (pubkey: string) => {
|
||||
const arr = follows.includes(pubkey)
|
||||
@ -64,8 +63,7 @@ export function FollowScreen() {
|
||||
setLoading(true);
|
||||
if (!follows.length) return navigate('/auth/finish');
|
||||
|
||||
const publish = await ark.createEvent({
|
||||
kind: NDKKind.Contacts,
|
||||
const publish = await ark.newContactList({
|
||||
tags: follows.map((item) => {
|
||||
if (item.startsWith('npub1')) return ['p', nip19.decode(item).data as string];
|
||||
return ['p', item];
|
||||
@ -73,11 +71,6 @@ export function FollowScreen() {
|
||||
});
|
||||
|
||||
if (publish) {
|
||||
ark.account.contacts = follows.map((item) => {
|
||||
if (item.startsWith('npub1')) return nip19.decode(item).data as string;
|
||||
return item;
|
||||
});
|
||||
|
||||
setLoading(false);
|
||||
return navigate('/auth/finish');
|
||||
}
|
||||
|
@ -7,7 +7,7 @@ import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { toast } from 'sonner';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
import { useArk } from '@libs/ark';
|
||||
import { useArk, useStorage } from '@libs/ark';
|
||||
import { ArrowLeftIcon, LoaderIcon } from '@shared/icons';
|
||||
import { User } from '@shared/user';
|
||||
|
||||
@ -20,6 +20,7 @@ export function ImportAccountScreen() {
|
||||
const [savedPrivkey, setSavedPrivkey] = useState(false);
|
||||
|
||||
const ark = useArk();
|
||||
const storage = useStorage();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const submitNpub = async () => {
|
||||
@ -42,8 +43,8 @@ export function ImportAccountScreen() {
|
||||
const pubkey = nip19.decode(npub.split('#')[0]).data as string;
|
||||
const localSigner = NDKPrivateKeySigner.generate();
|
||||
|
||||
await ark.createSetting('nsecbunker', '1');
|
||||
await ark.createPrivkey(`${npub}-nsecbunker`, localSigner.privateKey);
|
||||
await storage.createSetting('nsecbunker', '1');
|
||||
await storage.createPrivkey(`${npub}-nsecbunker`, localSigner.privateKey);
|
||||
|
||||
// open nsecbunker web app in default browser
|
||||
await open('https://app.nsecbunker.com/keys');
|
||||
@ -74,7 +75,7 @@ export function ImportAccountScreen() {
|
||||
setLoading(true);
|
||||
|
||||
// add account to db
|
||||
await ark.createAccount({ id: npub, pubkey });
|
||||
await storage.createAccount({ id: npub, pubkey });
|
||||
|
||||
// get account contacts
|
||||
await ark.getUserContacts({ pubkey });
|
||||
@ -99,7 +100,7 @@ export function ImportAccountScreen() {
|
||||
if (nsec.length > 50 && nsec.startsWith('nsec1')) {
|
||||
try {
|
||||
const privkey = nip19.decode(nsec).data as string;
|
||||
await ark.createPrivkey(pubkey, privkey);
|
||||
await storage.createPrivkey(pubkey, privkey);
|
||||
ark.updateNostrSigner({ signer: new NDKPrivateKeySigner(privkey) });
|
||||
|
||||
setSavedPrivkey(true);
|
||||
@ -279,9 +280,9 @@ export function ImportAccountScreen() {
|
||||
<p className="text-sm">
|
||||
Lume will put your private key to{' '}
|
||||
<b>
|
||||
{ark.platform === 'macos'
|
||||
{storage.platform === 'macos'
|
||||
? 'Apple Keychain (macOS)'
|
||||
: ark.platform === 'windows'
|
||||
: storage.platform === 'windows'
|
||||
? 'Credential Manager (Windows)'
|
||||
: 'Secret Service (Linux)'}
|
||||
</b>
|
||||
|
@ -2,11 +2,11 @@ import * as Switch from '@radix-ui/react-switch';
|
||||
import { isPermissionGranted, requestPermission } from '@tauri-apps/plugin-notification';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useArk } from '@libs/ark';
|
||||
import { useStorage } from '@libs/ark';
|
||||
import { InfoIcon } from '@shared/icons';
|
||||
|
||||
export function OnboardingScreen() {
|
||||
const ark = useArk();
|
||||
const storage = useStorage();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [settings, setSettings] = useState({
|
||||
@ -16,19 +16,18 @@ export function OnboardingScreen() {
|
||||
});
|
||||
|
||||
const next = () => {
|
||||
if (!ark.account.contacts.length) return navigate('/auth/follow');
|
||||
if (!storage.account.contacts.length) return navigate('/auth/follow');
|
||||
return navigate('/auth/finish');
|
||||
};
|
||||
|
||||
const toggleOutbox = async () => {
|
||||
await ark.createSetting('outbox', String(+!settings.outbox));
|
||||
await storage.createSetting('outbox', String(+!settings.outbox));
|
||||
// update state
|
||||
setSettings((prev) => ({ ...prev, outbox: !settings.outbox }));
|
||||
};
|
||||
|
||||
const toggleAutoupdate = async () => {
|
||||
await ark.createSetting('autoupdate', String(+!settings.autoupdate));
|
||||
ark.settings.autoupdate = !settings.autoupdate;
|
||||
await storage.createSetting('autoupdate', String(+!settings.autoupdate));
|
||||
// update state
|
||||
setSettings((prev) => ({ ...prev, autoupdate: !settings.autoupdate }));
|
||||
};
|
||||
@ -44,7 +43,7 @@ export function OnboardingScreen() {
|
||||
const permissionGranted = await isPermissionGranted();
|
||||
setSettings((prev) => ({ ...prev, notification: permissionGranted }));
|
||||
|
||||
const data = await ark.getAllSettings();
|
||||
const data = await storage.getAllSettings();
|
||||
if (!data) return;
|
||||
|
||||
data.forEach((item) => {
|
||||
|
@ -1,7 +1,7 @@
|
||||
import { NDKEvent } from '@nostr-dev-kit/ndk';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { TextNote } from '@libs/ark';
|
||||
import { EditIcon, ReactionIcon, ReplyIcon, RepostIcon, ZapIcon } from '@shared/icons';
|
||||
import { TextNote } from '@shared/notes';
|
||||
|
||||
export function TutorialNoteScreen() {
|
||||
const exampleEvent = new NDKEvent(undefined, {
|
||||
@ -32,7 +32,7 @@ export function TutorialNoteScreen() {
|
||||
updated in real-time.
|
||||
</p>
|
||||
<p className="px-3 font-semibold">Here is one example:</p>
|
||||
<TextNote event={exampleEvent} className="pointer-events-none my-2" />
|
||||
<TextNote event={exampleEvent} />
|
||||
<p className="px-3 font-semibold">Here are how you can interact with a note:</p>
|
||||
<div className="flex flex-col gap-2 px-3">
|
||||
<div className="inline-flex gap-3">
|
||||
|
@ -1,119 +0,0 @@
|
||||
import { NDKEvent } from '@nostr-dev-kit/ndk';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { VList, VListHandle } from 'virtua';
|
||||
import { ChatForm } from '@app/chats/components/chatForm';
|
||||
import { ChatMessage } from '@app/chats/components/message';
|
||||
import { useArk } from '@libs/ark';
|
||||
import { LoaderIcon } from '@shared/icons';
|
||||
import { User } from '@shared/user';
|
||||
|
||||
export function ChatScreen() {
|
||||
const ark = useArk();
|
||||
const { pubkey } = useParams();
|
||||
const { status, data } = useQuery({
|
||||
queryKey: ['nip04-dm', pubkey],
|
||||
queryFn: async () => {
|
||||
return await ark.getAllMessagesByPubkey({ pubkey });
|
||||
},
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
const listRef = useRef<VListHandle>(null);
|
||||
|
||||
const newMessage = useMutation({
|
||||
mutationFn: async (event: NDKEvent) => {
|
||||
// Cancel any outgoing refetches
|
||||
await queryClient.cancelQueries({ queryKey: ['nip04-dm', pubkey] });
|
||||
|
||||
// Snapshot the previous value
|
||||
const prevMessages = queryClient.getQueryData(['nip04-dm', pubkey]);
|
||||
|
||||
// Optimistically update to the new value
|
||||
queryClient.setQueryData(['nip04-dm', pubkey], (prev: NDKEvent[]) => [
|
||||
...prev,
|
||||
event,
|
||||
]);
|
||||
|
||||
// Return a context object with the snapshotted value
|
||||
return { prevMessages };
|
||||
},
|
||||
onSettled: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['nip04-dm', pubkey] });
|
||||
},
|
||||
});
|
||||
|
||||
const renderItem = useCallback(
|
||||
(message: NDKEvent) => {
|
||||
return (
|
||||
<ChatMessage
|
||||
key={message.id}
|
||||
message={message}
|
||||
isSelf={message.pubkey === ark.account.pubkey}
|
||||
/>
|
||||
);
|
||||
},
|
||||
[data]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (data && data.length > 0) listRef.current?.scrollToIndex(data.length);
|
||||
}, [data]);
|
||||
|
||||
useEffect(() => {
|
||||
const sub = ark.subscribe({
|
||||
filter: {
|
||||
kinds: [4],
|
||||
authors: [ark.account.pubkey],
|
||||
'#p': [pubkey],
|
||||
since: Math.floor(Date.now() / 1000),
|
||||
},
|
||||
closeOnEose: false,
|
||||
cb: (event) => newMessage.mutate(event),
|
||||
});
|
||||
|
||||
return () => {
|
||||
sub.stop();
|
||||
};
|
||||
}, [pubkey]);
|
||||
|
||||
return (
|
||||
<div className="h-full w-full p-3">
|
||||
<div className="rounded-lg bg-neutral-100 backdrop-blur-xl dark:bg-neutral-900">
|
||||
<div className="flex h-full flex-col justify-between overflow-hidden">
|
||||
<div className="flex h-16 shrink-0 items-center border-b border-neutral-200 px-3 dark:border-neutral-800">
|
||||
<User pubkey={pubkey} variant="simple" />
|
||||
</div>
|
||||
<div className="h-full w-full flex-1 px-3 py-3">
|
||||
{status === 'pending' ? (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<div className="flex flex-col items-center gap-1.5">
|
||||
<LoaderIcon className="h-5 w-5 animate-spin text-neutral-900 dark:text-neutral-100" />
|
||||
<p className="text-sm font-medium text-neutral-500 dark:text-neutral-300">
|
||||
Loading messages
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : data.length === 0 ? (
|
||||
<div className="absolute left-1/2 top-1/2 flex w-full -translate-x-1/2 -translate-y-1/2 transform flex-col gap-1 text-center">
|
||||
<h3 className="mb-2 text-4xl">🙌</h3>
|
||||
<p className="leading-none text-neutral-500 dark:text-neutral-300">
|
||||
You two didn't talk yet, let's send first message
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<VList ref={listRef} className="h-full scrollbar-none" shift={true} reverse>
|
||||
{data.map((message) => renderItem(message))}
|
||||
</VList>
|
||||
)}
|
||||
</div>
|
||||
<div className="shrink-0 rounded-b-lg border-t border-neutral-300 bg-neutral-200 p-3 dark:border-neutral-700 dark:bg-neutral-800">
|
||||
<ChatForm receiverPubkey={pubkey} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
@ -1,57 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import { MediaUploader } from '@app/chats/components/mediaUploader';
|
||||
import { useArk } from '@libs/ark';
|
||||
import { EnterIcon } from '@shared/icons';
|
||||
|
||||
export function ChatForm({ receiverPubkey }: { receiverPubkey: string }) {
|
||||
const ark = useArk();
|
||||
const [value, setValue] = useState('');
|
||||
|
||||
const submit = async () => {
|
||||
try {
|
||||
const publish = await ark.nip04Encrypt({ content: value, pubkey: receiverPubkey });
|
||||
if (publish) setValue('');
|
||||
} catch (e) {
|
||||
toast.error(e);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEnterPress = (e: {
|
||||
key: string;
|
||||
shiftKey: KeyboardEvent['shiftKey'];
|
||||
preventDefault: () => void;
|
||||
}) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
submit();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<MediaUploader setState={setValue} />
|
||||
<div className="flex w-full items-center justify-between rounded-full bg-neutral-300 px-3 dark:bg-neutral-700">
|
||||
<input
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
onKeyDown={handleEnterPress}
|
||||
spellCheck={false}
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
autoCapitalize="off"
|
||||
placeholder="Message..."
|
||||
className="h-10 flex-1 resize-none border-none bg-transparent px-3 text-neutral-900 placeholder:text-neutral-600 focus:border-none focus:shadow-none focus:outline-none focus:ring-0 dark:text-neutral-100 dark:placeholder:text-neutral-300"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={submit}
|
||||
className="inline-flex shrink-0 items-center gap-1.5 text-sm font-medium text-neutral-600 dark:text-neutral-300"
|
||||
>
|
||||
<EnterIcon className="h-5 w-5" />
|
||||
Send
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
@ -1,75 +0,0 @@
|
||||
import { NDKEvent } from '@nostr-dev-kit/ndk';
|
||||
import * as Avatar from '@radix-ui/react-avatar';
|
||||
import { minidenticon } from 'minidenticons';
|
||||
import { memo } from 'react';
|
||||
import { NavLink } from 'react-router-dom';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
import { useDecryptMessage } from '@app/chats/hooks/useDecryptMessage';
|
||||
import { displayNpub, formatCreatedAt } from '@utils/formater';
|
||||
import { useProfile } from '@utils/hooks/useProfile';
|
||||
|
||||
export const ChatListItem = memo(function ChatListItem({ event }: { event: NDKEvent }) {
|
||||
const { isLoading, user } = useProfile(event.pubkey);
|
||||
const decryptedContent = useDecryptMessage(event);
|
||||
|
||||
const createdAt = formatCreatedAt(event.created_at, true);
|
||||
const svgURI =
|
||||
'data:image/svg+xml;utf8,' + encodeURIComponent(minidenticon(event.pubkey, 90, 50));
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center gap-2.5 rounded-md px-3">
|
||||
<div className="h-10 w-10 shrink-0 animate-pulse rounded-lg bg-neutral-400 dark:bg-neutral-600" />
|
||||
<div className="flex w-full flex-col">
|
||||
<div className="h-2.5 w-1/2 animate-pulse rounded bg-neutral-400 dark:bg-neutral-600" />
|
||||
<div className="h-2.5 w-full animate-pulse rounded bg-neutral-400 dark:bg-neutral-600" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<NavLink
|
||||
to={`/chats/chat/${event.pubkey}`}
|
||||
preventScrollReset={true}
|
||||
className={({ isActive }) =>
|
||||
twMerge(
|
||||
'flex items-center gap-2.5 px-3 py-1.5 hover:bg-neutral-200 dark:hover:bg-neutral-800',
|
||||
isActive
|
||||
? 'bg-neutral-200 text-neutral-900 dark:bg-neutral-800 dark:text-neutral-100'
|
||||
: 'text-neutral-500 dark:text-neutral-300'
|
||||
)
|
||||
}
|
||||
>
|
||||
<Avatar.Root className="shrink-0">
|
||||
<Avatar.Image
|
||||
src={user?.picture || user?.image}
|
||||
alt={event.pubkey}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
style={{ contentVisibility: 'auto' }}
|
||||
className="h-10 w-10 rounded-lg"
|
||||
/>
|
||||
<Avatar.Fallback delayMs={300}>
|
||||
<img
|
||||
src={svgURI}
|
||||
alt={event.pubkey}
|
||||
className="h-10 w-10 rounded-lg bg-black dark:bg-white"
|
||||
/>
|
||||
</Avatar.Fallback>
|
||||
</Avatar.Root>
|
||||
<div className="flex w-full flex-col">
|
||||
<div className="max-w-[10rem] truncate font-semibold text-neutral-900 dark:text-neutral-100">
|
||||
{user?.name ||
|
||||
user?.display_name ||
|
||||
user?.displayName ||
|
||||
displayNpub(event.pubkey, 16)}
|
||||
</div>
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<div className="max-w-[10rem] truncate text-sm">{decryptedContent}</div>
|
||||
<div className="text-sm">{createdAt}</div>
|
||||
</div>
|
||||
</div>
|
||||
</NavLink>
|
||||
);
|
||||
});
|
@ -1,55 +0,0 @@
|
||||
import * as Tooltip from '@radix-ui/react-tooltip';
|
||||
import { Dispatch, SetStateAction, useState } from 'react';
|
||||
import { useArk } from '@libs/ark';
|
||||
import { LoaderIcon, MediaIcon } from '@shared/icons';
|
||||
|
||||
export function MediaUploader({
|
||||
setState,
|
||||
}: {
|
||||
setState: Dispatch<SetStateAction<string>>;
|
||||
}) {
|
||||
const ark = useArk();
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const uploadMedia = async () => {
|
||||
setLoading(true);
|
||||
|
||||
const image = await ark.upload({
|
||||
fileExts: ['mp4', 'mp3', 'webm', 'mkv', 'avi', 'mov'],
|
||||
});
|
||||
|
||||
if (image) {
|
||||
setState((prev: string) => `${prev}\n${image}`);
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Tooltip.Provider>
|
||||
<Tooltip.Root delayDuration={150}>
|
||||
<Tooltip.Trigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => uploadMedia()}
|
||||
className="group inline-flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-neutral-300 text-neutral-600 hover:bg-neutral-400 dark:bg-neutral-700 dark:text-neutral-300 dark:hover:bg-neutral-600"
|
||||
>
|
||||
{loading ? (
|
||||
<LoaderIcon className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<MediaIcon className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Portal>
|
||||
<Tooltip.Content
|
||||
className="-left-10 select-none rounded-md bg-black px-3.5 py-1.5 text-sm leading-none text-white will-change-[transform,opacity] data-[state=delayed-open]:data-[side=bottom]:animate-slideUpAndFade data-[state=delayed-open]:data-[side=left]:animate-slideRightAndFade data-[state=delayed-open]:data-[side=right]:animate-slideLeftAndFade data-[state=delayed-open]:data-[side=top]:animate-slideDownAndFade"
|
||||
sideOffset={5}
|
||||
>
|
||||
Upload media
|
||||
<Tooltip.Arrow className="fill-black" />
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Portal>
|
||||
</Tooltip.Root>
|
||||
</Tooltip.Provider>
|
||||
);
|
||||
}
|
@ -1,24 +0,0 @@
|
||||
import { NDKEvent } from '@nostr-dev-kit/ndk';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
import { useDecryptMessage } from '@app/chats/hooks/useDecryptMessage';
|
||||
|
||||
export function ChatMessage({ message, isSelf }: { message: NDKEvent; isSelf: boolean }) {
|
||||
const decryptedContent = useDecryptMessage(message);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={twMerge(
|
||||
'my-2 w-max max-w-[400px] rounded-t-xl px-3 py-3',
|
||||
isSelf
|
||||
? 'ml-auto rounded-l-xl bg-blue-500 text-white'
|
||||
: 'rounded-r-xl bg-neutral-200 text-neutral-900 dark:bg-neutral-800 dark:text-neutral-100'
|
||||
)}
|
||||
>
|
||||
{!decryptedContent ? (
|
||||
<p>Decrypting...</p>
|
||||
) : (
|
||||
<p className="select-text whitespace-pre-line break-all">{decryptedContent}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
@ -1,23 +0,0 @@
|
||||
import { NDKEvent } from '@nostr-dev-kit/ndk';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useArk } from '@libs/ark';
|
||||
|
||||
export function useDecryptMessage(event: NDKEvent) {
|
||||
const ark = useArk();
|
||||
const [content, setContent] = useState(event.content);
|
||||
|
||||
useEffect(() => {
|
||||
async function decryptContent() {
|
||||
try {
|
||||
const message = await ark.nip04Decrypt({ event });
|
||||
setContent(message);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
|
||||
decryptContent();
|
||||
}, []);
|
||||
|
||||
return content;
|
||||
}
|
@ -1,66 +0,0 @@
|
||||
import { NDKEvent } from '@nostr-dev-kit/ndk';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useCallback } from 'react';
|
||||
import { Outlet } from 'react-router-dom';
|
||||
import { ChatListItem } from '@app/chats/components/chatListItem';
|
||||
import { useArk } from '@libs/ark';
|
||||
import { LoaderIcon } from '@shared/icons';
|
||||
|
||||
export function ChatsScreen() {
|
||||
const ark = useArk();
|
||||
const { status, data } = useQuery({
|
||||
queryKey: ['nip04-chats'],
|
||||
queryFn: async () => {
|
||||
return await ark.getAllChats();
|
||||
},
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnMount: false,
|
||||
refetchOnReconnect: false,
|
||||
staleTime: Infinity,
|
||||
});
|
||||
|
||||
const renderItem = useCallback(
|
||||
(event: NDKEvent) => {
|
||||
return <ChatListItem key={event.id} event={event} />;
|
||||
},
|
||||
[data]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="grid h-full w-full grid-cols-3">
|
||||
<div className="col-span-1 h-full overflow-y-auto border-r border-neutral-200 scrollbar-none dark:border-neutral-800">
|
||||
<div
|
||||
data-tauri-drag-region
|
||||
className="flex h-11 w-full shrink-0 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 chats
|
||||
</h3>
|
||||
</div>
|
||||
<div className="flex h-full flex-col gap-1">
|
||||
{status === 'pending' ? (
|
||||
<div className="flex h-full w-full items-center justify-center pb-16">
|
||||
<div className="inline-flex flex-col items-center justify-center gap-2">
|
||||
<LoaderIcon className="h-5 w-5 animate-spin text-neutral-900 dark:text-neutral-100" />
|
||||
<h5 className="text-neutral-900 dark:text-neutral-100">
|
||||
Loading messages...
|
||||
</h5>
|
||||
</div>
|
||||
</div>
|
||||
) : data.length < 1 ? (
|
||||
<div className="flex h-full w-full items-center justify-center pb-16">
|
||||
<div className="inline-flex flex-col items-center justify-center gap-2">
|
||||
<h5 className="text-neutral-900 dark:text-neutral-100">No message</h5>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
data.map((item) => renderItem(item))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<Outlet />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
@ -1,12 +1,14 @@
|
||||
import { NDKKind } from '@nostr-dev-kit/ndk';
|
||||
import { useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import { useArk } from '@libs/ark';
|
||||
import { useArk, useStorage } from '@libs/ark';
|
||||
import { LoaderIcon, RunIcon } from '@shared/icons';
|
||||
import { User } from '@shared/user';
|
||||
|
||||
export function DepotContactCard() {
|
||||
const ark = useArk();
|
||||
const storage = useStorage();
|
||||
|
||||
const [status, setStatus] = useState(false);
|
||||
|
||||
const backupContact = async () => {
|
||||
@ -14,7 +16,7 @@ export function DepotContactCard() {
|
||||
setStatus(true);
|
||||
|
||||
const event = await ark.getEventByFilter({
|
||||
filter: { authors: [ark.account.pubkey], kinds: [NDKKind.Contacts] },
|
||||
filter: { authors: [storage.account.pubkey], kinds: [NDKKind.Contacts] },
|
||||
});
|
||||
|
||||
// broadcast to depot
|
||||
@ -34,13 +36,13 @@ export function DepotContactCard() {
|
||||
<div className="flex h-56 w-full flex-col gap-2 overflow-hidden rounded-xl bg-neutral-100 p-2 dark:bg-neutral-900">
|
||||
<div className="flex flex-1 items-center justify-center rounded-lg bg-neutral-200 dark:bg-neutral-800">
|
||||
<div className="isolate flex -space-x-2">
|
||||
{ark.account.contacts
|
||||
{storage.account.contacts
|
||||
?.slice(0, 8)
|
||||
.map((item) => <User key={item} pubkey={item} variant="ministacked" />)}
|
||||
{ark.account.contacts?.length > 8 ? (
|
||||
{storage.account.contacts?.length > 8 ? (
|
||||
<div className="inline-flex h-6 w-6 items-center justify-center rounded-full bg-neutral-300 text-neutral-900 ring-1 ring-white dark:bg-neutral-700 dark:text-neutral-100 dark:ring-black">
|
||||
<span className="text-[8px] font-medium">
|
||||
+{ark.account.contacts?.length - 8}
|
||||
+{storage.account.contacts?.length - 8}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
|
@ -1,12 +1,14 @@
|
||||
import { NDKKind } from '@nostr-dev-kit/ndk';
|
||||
import { useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import { useArk } from '@libs/ark';
|
||||
import { useArk, useStorage } from '@libs/ark';
|
||||
import { LoaderIcon, RunIcon } from '@shared/icons';
|
||||
import { User } from '@shared/user';
|
||||
|
||||
export function DepotProfileCard() {
|
||||
const ark = useArk();
|
||||
const storage = useStorage();
|
||||
|
||||
const [status, setStatus] = useState(false);
|
||||
|
||||
const backupProfile = async () => {
|
||||
@ -14,7 +16,7 @@ export function DepotProfileCard() {
|
||||
setStatus(true);
|
||||
|
||||
const event = await ark.getEventByFilter({
|
||||
filter: { authors: [ark.account.pubkey], kinds: [NDKKind.Metadata] },
|
||||
filter: { authors: [storage.account.pubkey], kinds: [NDKKind.Metadata] },
|
||||
});
|
||||
|
||||
// broadcast to depot
|
||||
@ -33,7 +35,7 @@ export function DepotProfileCard() {
|
||||
return (
|
||||
<div className="flex h-56 w-full flex-col gap-2 overflow-hidden rounded-xl bg-neutral-100 p-2 dark:bg-neutral-900">
|
||||
<div className="flex flex-1 items-center justify-center rounded-lg bg-neutral-200 dark:bg-neutral-800">
|
||||
<User pubkey={ark.account.pubkey} variant="simple" />
|
||||
<User pubkey={storage.account.pubkey} variant="simple" />
|
||||
</div>
|
||||
<div className="inline-flex shrink-0 items-center justify-between">
|
||||
<div className="text-sm font-medium">Profile</div>
|
||||
|
@ -1,11 +1,12 @@
|
||||
import { NDKKind } from '@nostr-dev-kit/ndk';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import { useArk } from '@libs/ark';
|
||||
import { useArk, useStorage } from '@libs/ark';
|
||||
import { LoaderIcon, RunIcon } from '@shared/icons';
|
||||
|
||||
export function DepotRelaysCard() {
|
||||
const ark = useArk();
|
||||
const storage = useStorage();
|
||||
|
||||
const [status, setStatus] = useState(false);
|
||||
const [relaySize, setRelaySize] = useState(0);
|
||||
@ -15,7 +16,7 @@ export function DepotRelaysCard() {
|
||||
setStatus(true);
|
||||
|
||||
const event = await ark.getEventByFilter({
|
||||
filter: { authors: [ark.account.pubkey], kinds: [NDKKind.RelayList] },
|
||||
filter: { authors: [storage.account.pubkey], kinds: [NDKKind.RelayList] },
|
||||
});
|
||||
|
||||
// broadcast to depot
|
||||
@ -34,7 +35,7 @@ export function DepotRelaysCard() {
|
||||
useEffect(() => {
|
||||
async function loadRelays() {
|
||||
const event = await ark.getEventByFilter({
|
||||
filter: { authors: [ark.account.pubkey], kinds: [NDKKind.RelayList] },
|
||||
filter: { authors: [storage.account.pubkey], kinds: [NDKKind.RelayList] },
|
||||
});
|
||||
if (event) setRelaySize(event.tags.length);
|
||||
}
|
||||
|
@ -8,11 +8,12 @@ import { DepotContactCard } from '@app/depot/components/contact';
|
||||
import { DepotMembers } from '@app/depot/components/members';
|
||||
import { DepotProfileCard } from '@app/depot/components/profile';
|
||||
import { DepotRelaysCard } from '@app/depot/components/relays';
|
||||
import { useArk } from '@libs/ark';
|
||||
import { useArk, useStorage } from '@libs/ark';
|
||||
import { ChevronDownIcon, DepotIcon, GossipIcon } from '@shared/icons';
|
||||
|
||||
export function DepotScreen() {
|
||||
const ark = useArk();
|
||||
const storage = useStorage();
|
||||
|
||||
const [dataPath, setDataPath] = useState('');
|
||||
const [tunnelUrl, setTunnelUrl] = useState('');
|
||||
@ -33,7 +34,7 @@ export function DepotScreen() {
|
||||
if (!/^([a-z0-9]+(-[a-z0-9]+)*\.)+[a-z]{2,}$/.test(relayUrl.host)) return;
|
||||
|
||||
const relayEvent = await ark.getEventByFilter({
|
||||
filter: { authors: [ark.account.pubkey], kinds: [NDKKind.RelayList] },
|
||||
filter: { authors: [storage.account.pubkey], kinds: [NDKKind.RelayList] },
|
||||
});
|
||||
|
||||
let publish: { id: string; seens: string[] };
|
||||
@ -54,7 +55,7 @@ export function DepotScreen() {
|
||||
});
|
||||
|
||||
if (publish) {
|
||||
await ark.createSetting('tunnel_url', tunnelUrl);
|
||||
await storage.createSetting('tunnel_url', tunnelUrl);
|
||||
toast.success('Update relay list successfully.');
|
||||
|
||||
setTunnelUrl('');
|
||||
|
@ -4,12 +4,13 @@ import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { parse, stringify } from 'smol-toml';
|
||||
import { toast } from 'sonner';
|
||||
import { useArk } from '@libs/ark';
|
||||
import { useArk, useStorage } from '@libs/ark';
|
||||
import { LoaderIcon } from '@shared/icons';
|
||||
import { delay } from '@utils/delay';
|
||||
|
||||
export function DepotOnboardingScreen() {
|
||||
const ark = useArk();
|
||||
const storage = useStorage();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
@ -24,15 +25,15 @@ export function DepotOnboardingScreen() {
|
||||
const parsedConfig = parse(config);
|
||||
|
||||
// add current user to whitelist
|
||||
parsedConfig.authorization['pubkey_whitelist'].push(ark.account.pubkey);
|
||||
parsedConfig.authorization['pubkey_whitelist'].push(storage.account.pubkey);
|
||||
|
||||
// update new config
|
||||
const newConfig = stringify(parsedConfig);
|
||||
await writeTextFile(defaultConfig, newConfig);
|
||||
|
||||
// launch depot
|
||||
await ark.launchDepot();
|
||||
await ark.createSetting('depot', '1');
|
||||
await storage.launchDepot();
|
||||
await storage.createSetting('depot', '1');
|
||||
await delay(2000); // delay 2s to make sure depot is running
|
||||
|
||||
// default depot url: ws://localhost:6090
|
||||
|
@ -3,7 +3,7 @@ import { message, save } from '@tauri-apps/plugin-dialog';
|
||||
import { writeTextFile } from '@tauri-apps/plugin-fs';
|
||||
import { relaunch } from '@tauri-apps/plugin-process';
|
||||
import { useRouteError } from 'react-router-dom';
|
||||
import { useArk } from '@libs/ark';
|
||||
import { useStorage } from '@libs/ark';
|
||||
|
||||
interface RouteError {
|
||||
statusText: string;
|
||||
@ -11,7 +11,7 @@ interface RouteError {
|
||||
}
|
||||
|
||||
export function ErrorScreen() {
|
||||
const ark = useArk();
|
||||
const storage = useStorage();
|
||||
const error = useRouteError() as RouteError;
|
||||
|
||||
const restart = async () => {
|
||||
@ -25,18 +25,18 @@ export function ErrorScreen() {
|
||||
const filePath = await save({
|
||||
defaultPath: downloadPath + '/' + fileName,
|
||||
});
|
||||
const nsec = await ark.loadPrivkey(ark.account.pubkey);
|
||||
const nsec = await storage.loadPrivkey(storage.account.pubkey);
|
||||
|
||||
if (filePath) {
|
||||
if (nsec) {
|
||||
await writeTextFile(
|
||||
filePath,
|
||||
`Nostr account, generated by Lume (lume.nu)\nPublic key: ${ark.account.id}\nPrivate key: ${nsec}`
|
||||
`Nostr account, generated by Lume (lume.nu)\nPublic key: ${storage.account.id}\nPrivate key: ${nsec}`
|
||||
);
|
||||
} else {
|
||||
await writeTextFile(
|
||||
filePath,
|
||||
`Nostr account, generated by Lume (lume.nu)\nPublic key: ${ark.account.id}`
|
||||
`Nostr account, generated by Lume (lume.nu)\nPublic key: ${storage.account.id}`
|
||||
);
|
||||
}
|
||||
} // else { user cancel action }
|
||||
|
2
src/app/home/components/index.ts
Normal file
2
src/app/home/components/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export * from './newsfeed';
|
||||
export * from './notification';
|
@ -2,21 +2,16 @@ import { NDKEvent, NDKKind } from '@nostr-dev-kit/ndk';
|
||||
import { useInfiniteQuery } from '@tanstack/react-query';
|
||||
import { useMemo, useRef } from 'react';
|
||||
import { VList, VListHandle } from 'virtua';
|
||||
import { Widget, useArk } from '@libs/ark';
|
||||
import { RepostNote, TextNote, Widget, useArk, useStorage } from '@libs/ark';
|
||||
import { ArrowRightCircleIcon, LoaderIcon, TimelineIcon } from '@shared/icons';
|
||||
import {
|
||||
MemoizedRepost,
|
||||
MemoizedTextNote,
|
||||
NoteSkeleton,
|
||||
UnknownNote,
|
||||
} from '@shared/notes';
|
||||
import { FETCH_LIMIT } from '@utils/constants';
|
||||
|
||||
export function NewsfeedWidget() {
|
||||
const ark = useArk();
|
||||
const storage = useStorage();
|
||||
const ref = useRef<VListHandle>();
|
||||
|
||||
const { status, data, hasNextPage, isFetchingNextPage, fetchNextPage } =
|
||||
const { data, hasNextPage, isLoading, isFetchingNextPage, fetchNextPage } =
|
||||
useInfiniteQuery({
|
||||
queryKey: ['newsfeed'],
|
||||
initialPageParam: 0,
|
||||
@ -30,9 +25,9 @@ export function NewsfeedWidget() {
|
||||
const events = await ark.getInfiniteEvents({
|
||||
filter: {
|
||||
kinds: [NDKKind.Text, NDKKind.Repost],
|
||||
authors: !ark.account.contacts.length
|
||||
? [ark.account.pubkey]
|
||||
: ark.account.contacts,
|
||||
authors: !storage.account.contacts.length
|
||||
? [storage.account.pubkey]
|
||||
: storage.account.contacts,
|
||||
},
|
||||
limit: FETCH_LIMIT,
|
||||
pageParam,
|
||||
@ -57,11 +52,11 @@ export function NewsfeedWidget() {
|
||||
const renderItem = (event: NDKEvent) => {
|
||||
switch (event.kind) {
|
||||
case NDKKind.Text:
|
||||
return <MemoizedTextNote key={event.id} event={event} />;
|
||||
return <TextNote key={event.id} event={event} />;
|
||||
case NDKKind.Repost:
|
||||
return <MemoizedRepost key={event.id} event={event} />;
|
||||
return <RepostNote key={event.id} event={event} />;
|
||||
default:
|
||||
return <UnknownNote key={event.id} event={event} />;
|
||||
return <TextNote key={event.id} event={event} />;
|
||||
}
|
||||
};
|
||||
|
||||
@ -75,11 +70,10 @@ export function NewsfeedWidget() {
|
||||
/>
|
||||
<Widget.Content>
|
||||
<VList ref={ref} overscan={2} className="flex-1">
|
||||
{status === 'pending' ? (
|
||||
<div className="px-3 py-1.5">
|
||||
<div className="rounded-xl bg-neutral-100 px-3 py-3 dark:bg-neutral-900">
|
||||
<NoteSkeleton />
|
||||
</div>
|
||||
{isLoading ? (
|
||||
<div className="inline-flex h-16 items-center justify-center gap-2 px-3 py-1.5">
|
||||
<LoaderIcon className="size-5" />
|
||||
Loading
|
||||
</div>
|
||||
) : (
|
||||
allEvents.map((item) => renderItem(item))
|
@ -2,14 +2,14 @@ import { NDKEvent, NDKKind, NDKSubscription } from '@nostr-dev-kit/ndk';
|
||||
import { useInfiniteQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useEffect, useMemo } from 'react';
|
||||
import { VList } from 'virtua';
|
||||
import { Widget, useArk } from '@libs/ark';
|
||||
import { NoteSkeleton, TextNote, Widget, useArk, useStorage } from '@libs/ark';
|
||||
import { AnnouncementIcon, ArrowRightCircleIcon, LoaderIcon } from '@shared/icons';
|
||||
import { MemoizedNotifyNote, NoteSkeleton } from '@shared/notes';
|
||||
import { FETCH_LIMIT } from '@utils/constants';
|
||||
import { sendNativeNotification } from '@utils/notification';
|
||||
|
||||
export function NotificationWidget() {
|
||||
const ark = useArk();
|
||||
const storage = useStorage();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { status, data, hasNextPage, isFetchingNextPage, fetchNextPage } =
|
||||
@ -26,7 +26,7 @@ export function NotificationWidget() {
|
||||
const events = await ark.getInfiniteEvents({
|
||||
filter: {
|
||||
kinds: [NDKKind.Text, NDKKind.Repost, NDKKind.Reaction, NDKKind.Zap],
|
||||
'#p': [ark.account.pubkey],
|
||||
'#p': [storage.account.pubkey],
|
||||
},
|
||||
limit: FETCH_LIMIT,
|
||||
pageParam,
|
||||
@ -52,17 +52,17 @@ export function NotificationWidget() {
|
||||
);
|
||||
|
||||
const renderEvent = (event: NDKEvent) => {
|
||||
if (event.pubkey === ark.account.pubkey) return null;
|
||||
return <MemoizedNotifyNote key={event.id} event={event} />;
|
||||
if (event.pubkey === storage.account.pubkey) return null;
|
||||
return <TextNote key={event.id} event={event} />;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
let sub: NDKSubscription = undefined;
|
||||
|
||||
if (status === 'success' && ark.account) {
|
||||
if (status === 'success' && storage.account) {
|
||||
const filter = {
|
||||
kinds: [NDKKind.Text, NDKKind.Repost, NDKKind.Reaction, NDKKind.Zap],
|
||||
'#p': [ark.account.pubkey],
|
||||
'#p': [storage.account.pubkey],
|
||||
since: Math.floor(Date.now() / 1000),
|
||||
};
|
||||
|
||||
@ -85,17 +85,6 @@ export function NotificationWidget() {
|
||||
return await sendNativeNotification(
|
||||
`${profile.displayName || profile.name} has replied to your note`
|
||||
);
|
||||
case NDKKind.EncryptedDirectMessage: {
|
||||
if (location.pathname !== '/chats') {
|
||||
return await sendNativeNotification(
|
||||
`${
|
||||
profile.displayName || profile.name
|
||||
} has send you a encrypted message`
|
||||
);
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
case NDKKind.Repost:
|
||||
return await sendNativeNotification(
|
||||
`${profile.displayName || profile.name} has reposted to your note`
|
||||
@ -133,11 +122,7 @@ export function NotificationWidget() {
|
||||
<Widget.Content>
|
||||
<VList className="flex-1" overscan={2}>
|
||||
{status === 'pending' ? (
|
||||
<div className="px-3 py-1.5">
|
||||
<div className="rounded-xl bg-neutral-100 px-3 py-3 dark:bg-neutral-900">
|
||||
<NoteSkeleton />
|
||||
</div>
|
||||
</div>
|
||||
<NoteSkeleton />
|
||||
) : allEvents.length < 1 ? (
|
||||
<div className="my-3 flex w-full items-center justify-center gap-2">
|
||||
<div>🎉</div>
|
@ -1,40 +1,21 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useRef, useState } from 'react';
|
||||
import { VList, VListHandle } from 'virtua';
|
||||
import { useArk } from '@libs/ark';
|
||||
import { NewsfeedWidget, NotificationWidget } from '@app/home/components';
|
||||
import { useStorage } from '@libs/ark';
|
||||
import { LoaderIcon } from '@shared/icons';
|
||||
import {
|
||||
ArticleWidget,
|
||||
FileWidget,
|
||||
GroupWidget,
|
||||
HashtagWidget,
|
||||
NewsfeedWidget,
|
||||
NotificationWidget,
|
||||
ThreadWidget,
|
||||
TopicWidget,
|
||||
TrendingAccountsWidget,
|
||||
TrendingNotesWidget,
|
||||
UserWidget,
|
||||
WidgetList,
|
||||
} from '@shared/widgets';
|
||||
import { WIDGET_KIND } from '@utils/constants';
|
||||
import { WidgetProps } from '@utils/types';
|
||||
|
||||
export function HomeScreen() {
|
||||
const ark = useArk();
|
||||
const storage = useStorage();
|
||||
const ref = useRef<VListHandle>(null);
|
||||
|
||||
const { isLoading, data } = useQuery({
|
||||
queryKey: ['widgets'],
|
||||
queryFn: async () => {
|
||||
const dbWidgets = await ark.getWidgets();
|
||||
const dbWidgets = await storage.getWidgets();
|
||||
const defaultWidgets = [
|
||||
{
|
||||
id: '9998',
|
||||
title: 'Notification',
|
||||
content: '',
|
||||
kind: WIDGET_KIND.notification,
|
||||
},
|
||||
{
|
||||
id: '9999',
|
||||
title: 'Newsfeed',
|
||||
@ -59,26 +40,6 @@ export function HomeScreen() {
|
||||
return <NotificationWidget key={widget.id} />;
|
||||
case WIDGET_KIND.newsfeed:
|
||||
return <NewsfeedWidget key={widget.id} />;
|
||||
case WIDGET_KIND.topic:
|
||||
return <TopicWidget key={widget.id} props={widget} />;
|
||||
case WIDGET_KIND.user:
|
||||
return <UserWidget key={widget.id} props={widget} />;
|
||||
case WIDGET_KIND.thread:
|
||||
return <ThreadWidget key={widget.id} props={widget} />;
|
||||
case WIDGET_KIND.article:
|
||||
return <ArticleWidget key={widget.id} props={widget} />;
|
||||
case WIDGET_KIND.file:
|
||||
return <FileWidget key={widget.id} props={widget} />;
|
||||
case WIDGET_KIND.hashtag:
|
||||
return <HashtagWidget key={widget.id} props={widget} />;
|
||||
case WIDGET_KIND.group:
|
||||
return <GroupWidget key={widget.id} props={widget} />;
|
||||
case WIDGET_KIND.trendingNotes:
|
||||
return <TrendingNotesWidget key={widget.id} props={widget} />;
|
||||
case WIDGET_KIND.trendingAccounts:
|
||||
return <TrendingAccountsWidget key={widget.id} props={widget} />;
|
||||
case WIDGET_KIND.list:
|
||||
return <WidgetList key={widget.id} props={widget} />;
|
||||
default:
|
||||
return <NewsfeedWidget key={widget.id} />;
|
||||
}
|
||||
|
@ -66,7 +66,7 @@ export function NewArticleScreen() {
|
||||
|
||||
const submit = async () => {
|
||||
try {
|
||||
if (!ark.readyToSign) return navigate('/new/privkey');
|
||||
if (!ark.ndk.signer) return navigate('/new/privkey');
|
||||
|
||||
setLoading(true);
|
||||
|
||||
|
@ -2,11 +2,11 @@ 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 { useArk } from '@libs/ark';
|
||||
import { useStorage } from '@libs/ark';
|
||||
import { MentionIcon } from '@shared/icons';
|
||||
|
||||
export function MentionPopup({ editor }: { editor: Editor }) {
|
||||
const ark = useArk();
|
||||
const storage = useStorage();
|
||||
|
||||
const insertMention = (pubkey: string) => {
|
||||
editor.commands.insertContent(`nostr:${nip19.npubEncode(pubkey)}`);
|
||||
@ -29,8 +29,8 @@ export function MentionPopup({ editor }: { editor: Editor }) {
|
||||
className="h-full max-h-[200px] w-[250px] overflow-hidden overflow-y-auto rounded-lg border border-neutral-200 bg-neutral-100 focus:outline-none dark:border-neutral-800 dark:bg-neutral-900"
|
||||
>
|
||||
<div className="flex flex-col gap-1 py-1">
|
||||
{ark.account.contacts.length ? (
|
||||
ark.account.contacts.map((item) => (
|
||||
{storage.account.contacts.length ? (
|
||||
storage.account.contacts.map((item) => (
|
||||
<button key={item} type="button" onClick={() => insertMention(item)}>
|
||||
<MentionPopupItem pubkey={item} />
|
||||
</button>
|
||||
|
@ -83,7 +83,7 @@ export function NewFileScreen() {
|
||||
|
||||
const submit = async () => {
|
||||
try {
|
||||
if (!ark.readyToSign) return navigate('/new/privkey');
|
||||
if (!ark.ndk.signer) return navigate('/new/privkey');
|
||||
|
||||
setIsPublish(true);
|
||||
|
||||
|
@ -11,12 +11,10 @@ import { useLayoutEffect, useRef, useState } from 'react';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { toast } from 'sonner';
|
||||
import { MediaUploader, MentionPopup } from '@app/new/components';
|
||||
import { useArk } from '@libs/ark';
|
||||
import { MentionNote, useArk, useWidget } from '@libs/ark';
|
||||
import { CancelIcon, LoaderIcon } from '@shared/icons';
|
||||
import { MentionNote } from '@shared/notes';
|
||||
import { WIDGET_KIND } from '@utils/constants';
|
||||
import { useSuggestion } from '@utils/hooks/useSuggestion';
|
||||
import { useWidget } from '@utils/hooks/useWidget';
|
||||
|
||||
export function NewPostScreen() {
|
||||
const ark = useArk();
|
||||
@ -64,7 +62,7 @@ export function NewPostScreen() {
|
||||
|
||||
const submit = async () => {
|
||||
try {
|
||||
if (!ark.readyToSign) return navigate('/new/privkey');
|
||||
if (!ark.ndk.signer) return navigate('/new/privkey');
|
||||
|
||||
setLoading(true);
|
||||
|
||||
@ -133,7 +131,7 @@ export function NewPostScreen() {
|
||||
/>
|
||||
{searchParams.get('replyTo') && (
|
||||
<div className="relative max-w-lg">
|
||||
<MentionNote id={searchParams.get('replyTo')} editing />
|
||||
<MentionNote eventId={searchParams.get('replyTo')} />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSearchParams({})}
|
||||
|
@ -3,10 +3,11 @@ import { getPublicKey, nip19 } from 'nostr-tools';
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { toast } from 'sonner';
|
||||
import { useArk } from '@libs/ark';
|
||||
import { useArk, useStorage } from '@libs/ark';
|
||||
|
||||
export function NewPrivkeyScreen() {
|
||||
const ark = useArk();
|
||||
const storage = useStorage();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [nsec, setNsec] = useState('');
|
||||
@ -23,7 +24,7 @@ export function NewPrivkeyScreen() {
|
||||
const privkey = decoded.data;
|
||||
const pubkey = getPublicKey(privkey);
|
||||
|
||||
if (pubkey !== ark.account.pubkey)
|
||||
if (pubkey !== storage.account.pubkey)
|
||||
return toast.info(
|
||||
'Your nsec is not match your current public key, please make sure you enter right nsec'
|
||||
);
|
||||
@ -31,7 +32,7 @@ export function NewPrivkeyScreen() {
|
||||
const signer = new NDKPrivateKeySigner(privkey);
|
||||
ark.updateNostrSigner({ signer });
|
||||
|
||||
if (isSave) await ark.createPrivkey(ark.account.pubkey, privkey);
|
||||
if (isSave) await storage.createPrivkey(storage.account.pubkey, privkey);
|
||||
|
||||
navigate(-1);
|
||||
} catch (e) {
|
||||
|
@ -1,124 +0,0 @@
|
||||
import { writeText } from '@tauri-apps/plugin-clipboard-manager';
|
||||
import Markdown from 'markdown-to-jsx';
|
||||
import { nip19 } from 'nostr-tools';
|
||||
import { EventPointer } from 'nostr-tools/lib/types/nip19';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { toast } from 'sonner';
|
||||
import { ArrowLeftIcon, CheckCircleIcon, ShareIcon } from '@shared/icons';
|
||||
import { NoteReplyForm } from '@shared/notes';
|
||||
import { ReplyList } from '@shared/notes/replies/list';
|
||||
import { useEvent } from '@utils/hooks/useEvent';
|
||||
|
||||
export function ArticleNoteScreen() {
|
||||
const { id } = useParams();
|
||||
const { status, data } = useEvent(id);
|
||||
|
||||
const [isCopy, setIsCopy] = useState(false);
|
||||
|
||||
const navigate = useNavigate();
|
||||
const metadata = useMemo(() => {
|
||||
if (status === 'pending') return;
|
||||
|
||||
const title = data.tags.find((tag) => tag[0] === 'title')?.[1];
|
||||
const image = data.tags.find((tag) => tag[0] === 'image')?.[1];
|
||||
const summary = data.tags.find((tag) => tag[0] === 'summary')?.[1];
|
||||
|
||||
let publishedAt: Date | string | number = data.tags.find(
|
||||
(tag) => tag[0] === 'published_at'
|
||||
)?.[1];
|
||||
|
||||
publishedAt = new Date(parseInt(publishedAt) * 1000).toLocaleDateString('en-US');
|
||||
|
||||
return {
|
||||
title,
|
||||
image,
|
||||
publishedAt,
|
||||
summary,
|
||||
};
|
||||
}, [data]);
|
||||
|
||||
const share = async () => {
|
||||
try {
|
||||
await writeText(
|
||||
'https://njump.me/' +
|
||||
nip19.neventEncode({ id: data?.id, author: data?.pubkey } as EventPointer)
|
||||
);
|
||||
// update state
|
||||
setIsCopy(true);
|
||||
// reset state after 2 sec
|
||||
setTimeout(() => setIsCopy(false), 2000);
|
||||
} catch (e) {
|
||||
toast.error(e);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-12 scroll-smooth px-4">
|
||||
<div className="col-span-1 flex flex-col items-start">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate(-1)}
|
||||
className="inline-flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-100 dark:bg-neutral-900"
|
||||
>
|
||||
<ArrowLeftIcon className="h-5 w-5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={share}
|
||||
className="inline-flex h-12 w-12 items-center justify-center rounded-t-xl"
|
||||
>
|
||||
{isCopy ? (
|
||||
<CheckCircleIcon className="h-5 w-5 text-teal-500" />
|
||||
) : (
|
||||
<ShareIcon className="h-5 w-5" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<div className="col-span-7 overflow-y-auto px-3 xl:col-span-8">
|
||||
{status === 'pending' ? (
|
||||
<div className="px-3 py-1.5">Loading...</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-2 border-b border-neutral-100 pb-4 dark:border-neutral-900">
|
||||
{metadata.image && (
|
||||
<img
|
||||
src={metadata.image}
|
||||
alt={metadata.title}
|
||||
className="h-auto w-full rounded-lg object-cover"
|
||||
/>
|
||||
)}
|
||||
<div>
|
||||
<h1 className="mb-2 text-3xl font-semibold">{metadata.title}</h1>
|
||||
<span className="text-sm font-medium text-neutral-600 dark:text-neutral-400">
|
||||
Published: {metadata.publishedAt.toString()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<Markdown
|
||||
options={{
|
||||
overrides: {
|
||||
a: {
|
||||
props: {
|
||||
className: 'text-blue-500 hover:text-blue-600',
|
||||
target: '_blank',
|
||||
},
|
||||
},
|
||||
},
|
||||
}}
|
||||
className="break-p prose-lg prose-neutral dark:prose-invert prose-ul:list-disc"
|
||||
>
|
||||
{data.content}
|
||||
</Markdown>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="col-span-4 border-l border-neutral-100 px-3 dark:border-neutral-900 xl:col-span-3">
|
||||
<div className="mb-3 border-b border-neutral-100 pb-3 dark:border-neutral-900">
|
||||
<NoteReplyForm rootEvent={data} />
|
||||
</div>
|
||||
<ReplyList eventId={id} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
@ -1,133 +0,0 @@
|
||||
import { NDKEvent, NDKKind } from '@nostr-dev-kit/ndk';
|
||||
import { writeText } from '@tauri-apps/plugin-clipboard-manager';
|
||||
import { nip19 } from 'nostr-tools';
|
||||
import { EventPointer } from 'nostr-tools/lib/types/nip19';
|
||||
import { useRef, useState } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { toast } from 'sonner';
|
||||
import { useArk } from '@libs/ark';
|
||||
import { ArrowLeftIcon, CheckCircleIcon, ReplyIcon, ShareIcon } from '@shared/icons';
|
||||
import {
|
||||
ChildNote,
|
||||
MemoizedTextKind,
|
||||
NoteActions,
|
||||
NoteReplyForm,
|
||||
UnknownNote,
|
||||
} from '@shared/notes';
|
||||
import { ReplyList } from '@shared/notes/replies/list';
|
||||
import { User } from '@shared/user';
|
||||
import { useEvent } from '@utils/hooks/useEvent';
|
||||
|
||||
export function TextNoteScreen() {
|
||||
const navigate = useNavigate();
|
||||
const replyRef = useRef(null);
|
||||
|
||||
const { id } = useParams();
|
||||
const ark = useArk();
|
||||
const { status, data } = useEvent(id);
|
||||
|
||||
const [isCopy, setIsCopy] = useState(false);
|
||||
|
||||
const share = async () => {
|
||||
try {
|
||||
await writeText(
|
||||
'https://njump.me/' +
|
||||
nip19.neventEncode({ id: data?.id, author: data?.pubkey } as EventPointer)
|
||||
);
|
||||
// update state
|
||||
setIsCopy(true);
|
||||
// reset state after 2 sec
|
||||
setTimeout(() => setIsCopy(false), 2000);
|
||||
} catch (e) {
|
||||
toast.error(e);
|
||||
}
|
||||
};
|
||||
|
||||
const scrollToReply = () => {
|
||||
replyRef.current.scrollIntoView();
|
||||
};
|
||||
|
||||
const renderKind = (event: NDKEvent) => {
|
||||
const thread = ark.getEventThread({ tags: event.tags });
|
||||
switch (event.kind) {
|
||||
case NDKKind.Text:
|
||||
return (
|
||||
<>
|
||||
{thread ? (
|
||||
<div className="mb-2 w-full px-3">
|
||||
<div className="flex h-min w-full flex-col gap-3 rounded-lg bg-neutral-100 p-3 dark:bg-neutral-900">
|
||||
{thread.rootEventId ? (
|
||||
<ChildNote id={thread.rootEventId} isRoot />
|
||||
) : null}
|
||||
{thread.replyEventId ? <ChildNote id={thread.replyEventId} /> : null}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
<MemoizedTextKind content={event.content} />
|
||||
</>
|
||||
);
|
||||
default:
|
||||
return <UnknownNote event={event} />;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="container mx-auto grid grid-cols-8 scroll-smooth px-4">
|
||||
<div className="col-span-1">
|
||||
<div className="flex flex-col items-end gap-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate(-1)}
|
||||
className="inline-flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-100 dark:bg-neutral-900"
|
||||
>
|
||||
<ArrowLeftIcon className="h-5 w-5" />
|
||||
</button>
|
||||
<div className="flex flex-col divide-y divide-neutral-200 rounded-xl bg-neutral-100 dark:divide-neutral-800 dark:bg-neutral-900">
|
||||
<button
|
||||
type="button"
|
||||
onClick={share}
|
||||
className="inline-flex h-12 w-12 items-center justify-center rounded-t-xl"
|
||||
>
|
||||
{isCopy ? (
|
||||
<CheckCircleIcon className="h-5 w-5 text-teal-500" />
|
||||
) : (
|
||||
<ShareIcon className="h-5 w-5" />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={scrollToReply}
|
||||
className="inline-flex h-12 w-12 items-center justify-center rounded-b-xl"
|
||||
>
|
||||
<ReplyIcon className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative col-span-6 flex flex-col overflow-y-auto">
|
||||
<div className="mx-auto w-full max-w-2xl">
|
||||
{status === 'pending' ? (
|
||||
<div className="px-3 py-1.5">Loading...</div>
|
||||
) : (
|
||||
<div className="flex h-min w-full flex-col px-3">
|
||||
<div className="mb-3 border-b border-neutral-100 pb-3 dark:border-neutral-900">
|
||||
<User pubkey={data.pubkey} time={data.created_at} variant="thread" />
|
||||
<div className="mt-3">{renderKind(data)}</div>
|
||||
<div className="mt-3">
|
||||
<NoteActions event={data} canOpenEvent={false} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div ref={replyRef} className="px-3">
|
||||
<div className="mb-3 border-b border-neutral-100 pb-3 dark:border-neutral-900">
|
||||
<NoteReplyForm rootEvent={data} />
|
||||
</div>
|
||||
<ReplyList eventId={id} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-span-1" />
|
||||
</div>
|
||||
);
|
||||
}
|
@ -1,10 +1,10 @@
|
||||
import { useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import { useArk } from '@libs/ark';
|
||||
import { useStorage } from '@libs/ark';
|
||||
import { LoaderIcon } from '@shared/icons';
|
||||
|
||||
export function NWCForm({ setWalletConnectURL }) {
|
||||
const ark = useArk();
|
||||
const storage = useStorage();
|
||||
|
||||
const [uri, setUri] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
@ -25,7 +25,7 @@ export function NWCForm({ setWalletConnectURL }) {
|
||||
const params = new URLSearchParams(uriObj.search);
|
||||
|
||||
if (params.has('relay') && params.has('secret')) {
|
||||
await ark.createPrivkey(`${ark.account.pubkey}-nwc`, uri);
|
||||
await storage.createPrivkey(`${storage.account.pubkey}-nwc`, uri);
|
||||
setWalletConnectURL(uri);
|
||||
setLoading(false);
|
||||
} else {
|
||||
|
@ -1,20 +1,20 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { NWCForm } from '@app/nwc/components/form';
|
||||
import { useArk } from '@libs/ark';
|
||||
import { useStorage } from '@libs/ark';
|
||||
import { CheckCircleIcon } from '@shared/icons';
|
||||
|
||||
export function NWCScreen() {
|
||||
const ark = useArk();
|
||||
const storage = useStorage();
|
||||
const [walletConnectURL, setWalletConnectURL] = useState<null | string>(null);
|
||||
|
||||
const remove = async () => {
|
||||
await ark.removePrivkey(`${ark.account.pubkey}-nwc`);
|
||||
await storage.removePrivkey(`${storage.account.pubkey}-nwc`);
|
||||
setWalletConnectURL(null);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
async function getNWC() {
|
||||
const nwc = await ark.loadPrivkey(`${ark.account.pubkey}-nwc`);
|
||||
const nwc = await storage.loadPrivkey(`${storage.account.pubkey}-nwc`);
|
||||
if (nwc) setWalletConnectURL(nwc);
|
||||
}
|
||||
getNWC();
|
||||
|
@ -2,14 +2,8 @@ import { NDKEvent, NDKKind } from '@nostr-dev-kit/ndk';
|
||||
import { useInfiniteQuery } from '@tanstack/react-query';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { VList } from 'virtua';
|
||||
import { useArk } from '@libs/ark';
|
||||
import { NoteSkeleton, RepostNote, TextNote, useArk } from '@libs/ark';
|
||||
import { ArrowRightCircleIcon, LoaderIcon } from '@shared/icons';
|
||||
import {
|
||||
MemoizedRepost,
|
||||
MemoizedTextNote,
|
||||
NoteSkeleton,
|
||||
UnknownNote,
|
||||
} from '@shared/notes';
|
||||
import { FETCH_LIMIT } from '@utils/constants';
|
||||
|
||||
export function RelayEventList({ relayUrl }: { relayUrl: string }) {
|
||||
@ -55,11 +49,11 @@ export function RelayEventList({ relayUrl }: { relayUrl: string }) {
|
||||
(event: NDKEvent) => {
|
||||
switch (event.kind) {
|
||||
case NDKKind.Text:
|
||||
return <MemoizedTextNote key={event.id} event={event} />;
|
||||
return <TextNote key={event.id} event={event} />;
|
||||
case NDKKind.Repost:
|
||||
return <MemoizedRepost key={event.id} event={event} />;
|
||||
return <RepostNote key={event.id} event={event} />;
|
||||
default:
|
||||
return <UnknownNote key={event.id} event={event} />;
|
||||
return <TextNote key={event.id} event={event} />;
|
||||
}
|
||||
},
|
||||
[data]
|
||||
@ -68,11 +62,7 @@ export function RelayEventList({ relayUrl }: { relayUrl: string }) {
|
||||
return (
|
||||
<VList className="mx-auto h-full w-full max-w-[500px] pt-10 scrollbar-none">
|
||||
{status === 'pending' ? (
|
||||
<div className="px-3 py-1.5">
|
||||
<div className="rounded-xl bg-neutral-100 px-3 py-3 dark:bg-neutral-900">
|
||||
<NoteSkeleton />
|
||||
</div>
|
||||
</div>
|
||||
<NoteSkeleton />
|
||||
) : (
|
||||
allEvents.map((item) => renderItem(item))
|
||||
)}
|
||||
|
@ -1,20 +1,22 @@
|
||||
import { NDKKind } from '@nostr-dev-kit/ndk';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { RelayForm } from '@app/relays/components/relayForm';
|
||||
import { useArk } from '@libs/ark';
|
||||
import { useArk, useStorage } from '@libs/ark';
|
||||
import { CancelIcon, RefreshIcon } from '@shared/icons';
|
||||
import { useRelay } from '@utils/hooks/useRelay';
|
||||
|
||||
export function UserRelayList() {
|
||||
const ark = useArk();
|
||||
const storage = useStorage();
|
||||
|
||||
const { removeRelay } = useRelay();
|
||||
const { status, data, refetch } = useQuery({
|
||||
queryKey: ['relays', ark.account.pubkey],
|
||||
queryKey: ['relays', storage.account.pubkey],
|
||||
queryFn: async () => {
|
||||
const event = await ark.getEventByFilter({
|
||||
filter: {
|
||||
kinds: [NDKKind.RelayList],
|
||||
authors: [ark.account.pubkey],
|
||||
authors: [storage.account.pubkey],
|
||||
},
|
||||
});
|
||||
|
||||
@ -24,7 +26,7 @@ export function UserRelayList() {
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
|
||||
const currentRelays = new Set([...ark.relays]);
|
||||
const currentRelays = new Set(ark.ndk.pool.connectedRelays().map((item) => item.url));
|
||||
|
||||
return (
|
||||
<div className="col-span-1">
|
||||
|
@ -1,21 +1,21 @@
|
||||
import { nip19 } from 'nostr-tools';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useArk } from '@libs/ark';
|
||||
import { useStorage } from '@libs/ark';
|
||||
import { EyeOffIcon } from '@shared/icons';
|
||||
|
||||
export function BackupSettingScreen() {
|
||||
const ark = useArk();
|
||||
const storage = useStorage();
|
||||
|
||||
const [privkey, setPrivkey] = useState(null);
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
|
||||
const removePrivkey = async () => {
|
||||
await ark.removePrivkey(ark.account.pubkey);
|
||||
await storage.removePrivkey(storage.account.pubkey);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
async function loadPrivkey() {
|
||||
const key = await ark.loadPrivkey(ark.account.pubkey);
|
||||
const key = await storage.loadPrivkey(storage.account.pubkey);
|
||||
if (key) setPrivkey(key);
|
||||
}
|
||||
|
||||
|
@ -1,17 +1,17 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { fetch } from '@tauri-apps/plugin-http';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useArk } from '@libs/ark';
|
||||
import { useStorage } from '@libs/ark';
|
||||
import { LoaderIcon } from '@shared/icons';
|
||||
import { compactNumber } from '@utils/formater';
|
||||
|
||||
export function PostCard() {
|
||||
const ark = useArk();
|
||||
const storage = useStorage();
|
||||
const { status, data } = useQuery({
|
||||
queryKey: ['user-stats', ark.account.pubkey],
|
||||
queryKey: ['user-stats', storage.account.pubkey],
|
||||
queryFn: async ({ signal }: { signal: AbortSignal }) => {
|
||||
const res = await fetch(
|
||||
`https://api.nostr.band/v0/stats/profile/${ark.account.pubkey}`,
|
||||
`https://api.nostr.band/v0/stats/profile/${storage.account.pubkey}`,
|
||||
{
|
||||
signal,
|
||||
}
|
||||
@ -38,14 +38,14 @@ export function PostCard() {
|
||||
) : (
|
||||
<div className="flex h-full w-full flex-col justify-between p-4">
|
||||
<h3 className="pt-1 text-5xl font-semibold tabular-nums text-neutral-900 dark:text-neutral-100">
|
||||
{compactNumber.format(data.stats[ark.account.pubkey].pub_note_count)}
|
||||
{compactNumber.format(data.stats[storage.account.pubkey].pub_note_count)}
|
||||
</h3>
|
||||
<div className="mt-auto flex h-6 w-full items-center justify-between">
|
||||
<p className="text-xl font-medium leading-none text-neutral-600 dark:text-neutral-400">
|
||||
Posts
|
||||
</p>
|
||||
<Link
|
||||
to={`/users/${ark.account.pubkey}`}
|
||||
to={`/users/${storage.account.pubkey}`}
|
||||
className="inline-flex h-6 w-max items-center gap-1 rounded-full bg-neutral-200 px-2.5 text-sm font-medium hover:bg-neutral-300 dark:bg-neutral-800 dark:hover:bg-neutral-700"
|
||||
>
|
||||
View
|
||||
|
@ -3,21 +3,21 @@ import { writeText } from '@tauri-apps/plugin-clipboard-manager';
|
||||
import { minidenticon } from 'minidenticons';
|
||||
import { nip19 } from 'nostr-tools';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useArk } from '@libs/ark';
|
||||
import { useStorage } from '@libs/ark';
|
||||
import { EditIcon, LoaderIcon } from '@shared/icons';
|
||||
import { displayNpub } from '@utils/formater';
|
||||
import { useProfile } from '@utils/hooks/useProfile';
|
||||
|
||||
export function ProfileCard() {
|
||||
const ark = useArk();
|
||||
const storage = useStorage();
|
||||
const svgURI =
|
||||
'data:image/svg+xml;utf8,' +
|
||||
encodeURIComponent(minidenticon(ark.account.pubkey, 90, 50));
|
||||
encodeURIComponent(minidenticon(storage.account.pubkey, 90, 50));
|
||||
|
||||
const { isLoading, user } = useProfile(ark.account.pubkey);
|
||||
const { isLoading, user } = useProfile(storage.account.pubkey);
|
||||
|
||||
const copyNpub = async () => {
|
||||
return await writeText(nip19.npubEncode(ark.account.pubkey));
|
||||
return await writeText(nip19.npubEncode(storage.account.pubkey));
|
||||
};
|
||||
|
||||
return (
|
||||
@ -48,7 +48,7 @@ export function ProfileCard() {
|
||||
<Avatar.Root className="shrink-0">
|
||||
<Avatar.Image
|
||||
src={user?.picture || user?.image}
|
||||
alt={ark.account.pubkey}
|
||||
alt={storage.account.pubkey}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
style={{ contentVisibility: 'auto' }}
|
||||
@ -57,7 +57,7 @@ export function ProfileCard() {
|
||||
<Avatar.Fallback delayMs={300}>
|
||||
<img
|
||||
src={svgURI}
|
||||
alt={ark.account.pubkey}
|
||||
alt={storage.account.pubkey}
|
||||
className="h-16 w-16 rounded-xl bg-black dark:bg-white"
|
||||
/>
|
||||
</Avatar.Fallback>
|
||||
@ -67,7 +67,7 @@ export function ProfileCard() {
|
||||
{user?.display_name || user?.name}
|
||||
</h3>
|
||||
<p className="text-lg text-neutral-700 dark:text-neutral-300">
|
||||
{user?.nip05 || displayNpub(ark.account.pubkey, 16)}
|
||||
{user?.nip05 || displayNpub(storage.account.pubkey, 16)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -1,13 +1,15 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useArk } from '@libs/ark';
|
||||
import { useArk, useStorage } from '@libs/ark';
|
||||
import { EditIcon, LoaderIcon } from '@shared/icons';
|
||||
import { compactNumber } from '@utils/formater';
|
||||
|
||||
export function RelayCard() {
|
||||
const ark = useArk();
|
||||
const storage = useStorage();
|
||||
|
||||
const { status, data } = useQuery({
|
||||
queryKey: ['relays', ark.account.pubkey],
|
||||
queryKey: ['relays', storage.account.pubkey],
|
||||
queryFn: async () => {
|
||||
const relays = await ark.getUserRelays({});
|
||||
return relays;
|
||||
|
@ -1,16 +1,16 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { fetch } from '@tauri-apps/plugin-http';
|
||||
import { useArk } from '@libs/ark';
|
||||
import { useStorage } from '@libs/ark';
|
||||
import { LoaderIcon } from '@shared/icons';
|
||||
import { compactNumber } from '@utils/formater';
|
||||
|
||||
export function ZapCard() {
|
||||
const ark = useArk();
|
||||
const storage = useStorage();
|
||||
const { status, data } = useQuery({
|
||||
queryKey: ['user-stats', ark.account.pubkey],
|
||||
queryKey: ['user-stats', storage.account.pubkey],
|
||||
queryFn: async ({ signal }: { signal: AbortSignal }) => {
|
||||
const res = await fetch(
|
||||
`https://api.nostr.band/v0/stats/profile/${ark.account.pubkey}`,
|
||||
`https://api.nostr.band/v0/stats/profile/${storage.account.pubkey}`,
|
||||
{
|
||||
signal,
|
||||
}
|
||||
@ -38,7 +38,7 @@ export function ZapCard() {
|
||||
<div className="flex h-full w-full flex-col justify-between p-4">
|
||||
<h3 className="pt-1 text-5xl font-semibold tabular-nums text-neutral-900 dark:text-neutral-100">
|
||||
{compactNumber.format(
|
||||
data?.stats[ark.account.pubkey]?.zaps_received?.msats / 1000 || 0
|
||||
data?.stats[storage.account.pubkey]?.zaps_received?.msats / 1000 || 0
|
||||
)}
|
||||
</h3>
|
||||
<div className="mt-auto flex h-6 items-center text-xl font-medium leading-none text-neutral-600 dark:text-neutral-400">
|
||||
|
@ -4,7 +4,7 @@ import { message } from '@tauri-apps/plugin-dialog';
|
||||
import { useState } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useArk } from '@libs/ark';
|
||||
import { useArk, useStorage } from '@libs/ark';
|
||||
import { CheckCircleIcon, LoaderIcon, PlusIcon, UnverifiedIcon } from '@shared/icons';
|
||||
|
||||
export function EditProfileScreen() {
|
||||
@ -14,6 +14,8 @@ export function EditProfileScreen() {
|
||||
const [nip05, setNIP05] = useState({ verified: true, text: '' });
|
||||
|
||||
const ark = useArk();
|
||||
const storage = useStorage();
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
@ -22,7 +24,10 @@ export function EditProfileScreen() {
|
||||
formState: { isValid, errors },
|
||||
} = useForm({
|
||||
defaultValues: async () => {
|
||||
const res: NDKUserProfile = queryClient.getQueryData(['user', ark.account.pubkey]);
|
||||
const res: NDKUserProfile = queryClient.getQueryData([
|
||||
'user',
|
||||
storage.account.pubkey,
|
||||
]);
|
||||
if (res.image) {
|
||||
setPicture(res.image);
|
||||
}
|
||||
@ -41,7 +46,7 @@ export function EditProfileScreen() {
|
||||
|
||||
const uploadAvatar = async () => {
|
||||
try {
|
||||
if (!ark.readyToSign) return navigate('/new/privkey');
|
||||
if (!ark.ndk.signer) return navigate('/new/privkey');
|
||||
|
||||
setLoading(true);
|
||||
|
||||
@ -85,7 +90,10 @@ export function EditProfileScreen() {
|
||||
};
|
||||
|
||||
if (data.nip05) {
|
||||
const verify = ark.validateNIP05({ pubkey: ark.account.pubkey, nip05: data.nip05 });
|
||||
const verify = ark.validateNIP05({
|
||||
pubkey: storage.account.pubkey,
|
||||
nip05: data.nip05,
|
||||
});
|
||||
if (verify) {
|
||||
content = { ...content, nip05: data.nip05 };
|
||||
} else {
|
||||
@ -106,7 +114,7 @@ export function EditProfileScreen() {
|
||||
if (publish) {
|
||||
// invalid cache
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ['user', ark.account.pubkey],
|
||||
queryKey: ['user', storage.account.pubkey],
|
||||
});
|
||||
// reset form
|
||||
reset();
|
||||
|
@ -5,11 +5,12 @@ import { disable, enable, isEnabled } from '@tauri-apps/plugin-autostart';
|
||||
import { isPermissionGranted, requestPermission } from '@tauri-apps/plugin-notification';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
import { useArk } from '@libs/ark';
|
||||
import { useStorage } from '@libs/ark';
|
||||
import { DarkIcon, LightIcon, SystemModeIcon } from '@shared/icons';
|
||||
|
||||
export function GeneralSettingScreen() {
|
||||
const ark = useArk();
|
||||
const storage = useStorage();
|
||||
|
||||
const [settings, setSettings] = useState({
|
||||
autoupdate: false,
|
||||
autolaunch: false,
|
||||
@ -39,28 +40,28 @@ export function GeneralSettingScreen() {
|
||||
};
|
||||
|
||||
const toggleOutbox = async () => {
|
||||
await ark.createSetting('outbox', String(+!settings.outbox));
|
||||
await storage.createSetting('outbox', String(+!settings.outbox));
|
||||
// update state
|
||||
setSettings((prev) => ({ ...prev, outbox: !settings.outbox }));
|
||||
};
|
||||
|
||||
const toggleMedia = async () => {
|
||||
await ark.createSetting('media', String(+!settings.media));
|
||||
ark.settings.media = !settings.media;
|
||||
await storage.createSetting('media', String(+!settings.media));
|
||||
storage.settings.media = !settings.media;
|
||||
// update state
|
||||
setSettings((prev) => ({ ...prev, media: !settings.media }));
|
||||
};
|
||||
|
||||
const toggleHashtag = async () => {
|
||||
await ark.createSetting('hashtag', String(+!settings.hashtag));
|
||||
ark.settings.hashtag = !settings.hashtag;
|
||||
await storage.createSetting('hashtag', String(+!settings.hashtag));
|
||||
storage.settings.hashtag = !settings.hashtag;
|
||||
// update state
|
||||
setSettings((prev) => ({ ...prev, hashtag: !settings.hashtag }));
|
||||
};
|
||||
|
||||
const toggleAutoupdate = async () => {
|
||||
await ark.createSetting('autoupdate', String(+!settings.autoupdate));
|
||||
ark.settings.autoupdate = !settings.autoupdate;
|
||||
await storage.createSetting('autoupdate', String(+!settings.autoupdate));
|
||||
storage.settings.autoupdate = !settings.autoupdate;
|
||||
// update state
|
||||
setSettings((prev) => ({ ...prev, autoupdate: !settings.autoupdate }));
|
||||
};
|
||||
@ -84,7 +85,7 @@ export function GeneralSettingScreen() {
|
||||
const permissionGranted = await isPermissionGranted();
|
||||
setSettings((prev) => ({ ...prev, notification: permissionGranted }));
|
||||
|
||||
const data = await ark.getAllSettings();
|
||||
const data = await storage.getAllSettings();
|
||||
if (!data) return;
|
||||
|
||||
data.forEach((item) => {
|
||||
|
@ -4,13 +4,15 @@ import { useEffect, useState } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { toast } from 'sonner';
|
||||
import { UserStats } from '@app/users/components/stats';
|
||||
import { useArk } from '@libs/ark';
|
||||
import { useArk, useStorage } from '@libs/ark';
|
||||
import { NIP05 } from '@shared/nip05';
|
||||
import { displayNpub } from '@utils/formater';
|
||||
import { useProfile } from '@utils/hooks/useProfile';
|
||||
|
||||
export function UserProfile({ pubkey }: { pubkey: string }) {
|
||||
const ark = useArk();
|
||||
const storage = useStorage();
|
||||
|
||||
const { user } = useProfile(pubkey);
|
||||
|
||||
const [followed, setFollowed] = useState(false);
|
||||
@ -21,7 +23,7 @@ export function UserProfile({ pubkey }: { pubkey: string }) {
|
||||
|
||||
const follow = async () => {
|
||||
try {
|
||||
if (!ark.readyToSign) return navigate('/new/privkey');
|
||||
if (!ark.ndk.signer) return navigate('/new/privkey');
|
||||
setFollowed(true);
|
||||
|
||||
const add = await ark.createContact({ pubkey });
|
||||
@ -38,7 +40,7 @@ export function UserProfile({ pubkey }: { pubkey: string }) {
|
||||
|
||||
const unfollow = async () => {
|
||||
try {
|
||||
if (!ark.readyToSign) return navigate('/new/privkey');
|
||||
if (!ark.ndk.signer) return navigate('/new/privkey');
|
||||
setFollowed(false);
|
||||
|
||||
await ark.deleteContact({ pubkey });
|
||||
@ -48,7 +50,7 @@ export function UserProfile({ pubkey }: { pubkey: string }) {
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (ark.account.contacts.includes(pubkey)) {
|
||||
if (storage.account.contacts.includes(pubkey)) {
|
||||
setFollowed(true);
|
||||
}
|
||||
}, []);
|
||||
|
@ -3,14 +3,8 @@ import { useInfiniteQuery } from '@tanstack/react-query';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { UserProfile } from '@app/users/components/profile';
|
||||
import { useArk } from '@libs/ark';
|
||||
import { NoteSkeleton, RepostNote, TextNote, useArk } from '@libs/ark';
|
||||
import { ArrowRightCircleIcon, LoaderIcon } from '@shared/icons';
|
||||
import {
|
||||
MemoizedRepost,
|
||||
MemoizedTextNote,
|
||||
NoteSkeleton,
|
||||
UnknownNote,
|
||||
} from '@shared/notes';
|
||||
import { FETCH_LIMIT } from '@utils/constants';
|
||||
|
||||
export function UserScreen() {
|
||||
@ -57,11 +51,11 @@ export function UserScreen() {
|
||||
(event: NDKEvent) => {
|
||||
switch (event.kind) {
|
||||
case NDKKind.Text:
|
||||
return <MemoizedTextNote key={event.id} event={event} />;
|
||||
return <TextNote key={event.id} event={event} />;
|
||||
case NDKKind.Repost:
|
||||
return <MemoizedRepost key={event.id} event={event} />;
|
||||
return <RepostNote key={event.id} event={event} />;
|
||||
default:
|
||||
return <UnknownNote key={event.id} event={event} />;
|
||||
return <TextNote key={event.id} event={event} />;
|
||||
}
|
||||
},
|
||||
[data]
|
||||
@ -76,11 +70,7 @@ export function UserScreen() {
|
||||
</h3>
|
||||
<div className="mx-auto flex h-full max-w-[500px] flex-col justify-between gap-1.5 pb-4 pt-1.5">
|
||||
{status === 'pending' ? (
|
||||
<div className="px-3 py-1.5">
|
||||
<div className="rounded-xl bg-neutral-100 px-3 py-3 dark:bg-neutral-900">
|
||||
<NoteSkeleton />
|
||||
</div>
|
||||
</div>
|
||||
<NoteSkeleton />
|
||||
) : (
|
||||
allEvents.map((item) => renderItem(item))
|
||||
)}
|
||||
|
@ -10,76 +10,37 @@ import NDK, {
|
||||
NDKUser,
|
||||
NostrEvent,
|
||||
} from '@nostr-dev-kit/ndk';
|
||||
import { ndkAdapter } from '@nostr-fetch/adapter-ndk';
|
||||
import { appConfigDir, resolveResource } from '@tauri-apps/api/path';
|
||||
import { invoke } from '@tauri-apps/api/primitives';
|
||||
import { open } from '@tauri-apps/plugin-dialog';
|
||||
import { readBinaryFile } from '@tauri-apps/plugin-fs';
|
||||
import { fetch } from '@tauri-apps/plugin-http';
|
||||
import { Platform } from '@tauri-apps/plugin-os';
|
||||
import { Child, Command } from '@tauri-apps/plugin-shell';
|
||||
import Database from '@tauri-apps/plugin-sql';
|
||||
import {
|
||||
NostrEventExt,
|
||||
NostrFetcher,
|
||||
normalizeRelayUrl,
|
||||
normalizeRelayUrlSet,
|
||||
} from 'nostr-fetch';
|
||||
import { NostrFetcher, normalizeRelayUrl } from 'nostr-fetch';
|
||||
import { nip19 } from 'nostr-tools';
|
||||
import { NDKCacheAdapterTauri } from '@libs/cache';
|
||||
import { delay } from '@utils/delay';
|
||||
import {
|
||||
type Account,
|
||||
type NDKCacheUser,
|
||||
type NDKCacheUserProfile,
|
||||
type NDKEventWithReplies,
|
||||
type NIP05,
|
||||
type WidgetProps,
|
||||
} from '@utils/types';
|
||||
import { LumeStorage } from '@libs/storage';
|
||||
import { Account, type NDKEventWithReplies, type NIP05 } from '@utils/types';
|
||||
|
||||
export class Ark {
|
||||
#storage: Database;
|
||||
#depot: Child;
|
||||
#storage: LumeStorage;
|
||||
#fetcher: NostrFetcher;
|
||||
public ndk: NDK;
|
||||
public fetcher: NostrFetcher;
|
||||
public account: Account | null;
|
||||
public relays: string[] | null;
|
||||
public readyToSign: boolean;
|
||||
readonly platform: Platform | null;
|
||||
readonly settings: {
|
||||
autoupdate: boolean;
|
||||
bunker: boolean;
|
||||
outbox: boolean;
|
||||
media: boolean;
|
||||
hashtag: boolean;
|
||||
depot: boolean;
|
||||
tunnelUrl: string;
|
||||
};
|
||||
public account: Account;
|
||||
|
||||
constructor({ storage, platform }: { storage: Database; platform: Platform }) {
|
||||
constructor({
|
||||
ndk,
|
||||
storage,
|
||||
|
||||
fetcher,
|
||||
}: {
|
||||
ndk: NDK;
|
||||
storage: LumeStorage;
|
||||
|
||||
fetcher: NostrFetcher;
|
||||
}) {
|
||||
this.ndk = ndk;
|
||||
this.#storage = storage;
|
||||
this.platform = platform;
|
||||
this.settings = {
|
||||
autoupdate: false,
|
||||
bunker: false,
|
||||
outbox: false,
|
||||
media: true,
|
||||
hashtag: true,
|
||||
depot: false,
|
||||
tunnelUrl: '',
|
||||
};
|
||||
}
|
||||
|
||||
public async launchDepot() {
|
||||
const configPath = await resolveResource('resources/config.toml');
|
||||
const dataPath = await appConfigDir();
|
||||
|
||||
const command = Command.sidecar('bin/depot', ['-c', configPath, '-d', dataPath]);
|
||||
this.#depot = await command.spawn();
|
||||
this.#fetcher = fetcher;
|
||||
}
|
||||
|
||||
public async connectDepot() {
|
||||
if (!this.#depot) return;
|
||||
return this.ndk.addExplicitRelay(
|
||||
new NDKRelay(normalizeRelayUrl('ws://localhost:6090')),
|
||||
undefined,
|
||||
@ -87,349 +48,11 @@ export class Ark {
|
||||
);
|
||||
}
|
||||
|
||||
public checkDepot() {
|
||||
if (this.#depot) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
async #keyring_save(key: string, value: string) {
|
||||
return await invoke('secure_save', { key, value });
|
||||
}
|
||||
|
||||
async #keyring_load(key: string) {
|
||||
try {
|
||||
const value: string = await invoke('secure_load', { key });
|
||||
if (!value) return null;
|
||||
return value;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async #keyring_remove(key: string) {
|
||||
return await invoke('secure_remove', { key });
|
||||
}
|
||||
|
||||
async #initNostrSigner({ nsecbunker }: { nsecbunker?: boolean }) {
|
||||
const account = await this.getActiveAccount();
|
||||
if (!account) return null;
|
||||
|
||||
// update active account
|
||||
this.account = account;
|
||||
|
||||
try {
|
||||
// NIP-46 Signer
|
||||
if (nsecbunker) {
|
||||
const localSignerPrivkey = await this.#keyring_load(
|
||||
`${this.account.id}-nsecbunker`
|
||||
);
|
||||
|
||||
if (!localSignerPrivkey) {
|
||||
this.readyToSign = false;
|
||||
return null;
|
||||
}
|
||||
|
||||
const localSigner = new NDKPrivateKeySigner(localSignerPrivkey);
|
||||
const bunker = new NDK({
|
||||
explicitRelayUrls: normalizeRelayUrlSet([
|
||||
'wss://relay.nsecbunker.com/',
|
||||
'wss://nostr.vulpem.com/',
|
||||
]),
|
||||
});
|
||||
await bunker.connect(3000);
|
||||
|
||||
const remoteSigner = new NDKNip46Signer(bunker, this.account.pubkey, localSigner);
|
||||
await remoteSigner.blockUntilReady();
|
||||
|
||||
this.readyToSign = true;
|
||||
return remoteSigner;
|
||||
}
|
||||
|
||||
// Privkey Signer
|
||||
const userPrivkey = await this.#keyring_load(this.account.pubkey);
|
||||
|
||||
if (!userPrivkey) {
|
||||
this.readyToSign = false;
|
||||
return null;
|
||||
}
|
||||
|
||||
this.readyToSign = true;
|
||||
return new NDKPrivateKeySigner(userPrivkey);
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public async init() {
|
||||
const settings = await this.getAllSettings();
|
||||
|
||||
for (const item of settings) {
|
||||
if (item.key === 'nsecbunker') this.settings.bunker = !!parseInt(item.value);
|
||||
if (item.key === 'outbox') this.settings.outbox = !!parseInt(item.value);
|
||||
if (item.key === 'media') this.settings.media = !!parseInt(item.value);
|
||||
if (item.key === 'hashtag') this.settings.hashtag = !!parseInt(item.value);
|
||||
if (item.key === 'autoupdate') this.settings.autoupdate = !!parseInt(item.value);
|
||||
if (item.key === 'depot') this.settings.depot = !!parseInt(item.value);
|
||||
if (item.key === 'tunnel_url') this.settings.tunnelUrl = item.value;
|
||||
}
|
||||
|
||||
const explicitRelayUrls = normalizeRelayUrlSet([
|
||||
'wss://relay.damus.io',
|
||||
'wss://relay.nostr.band/all',
|
||||
'wss://nostr.mutinywallet.com',
|
||||
]);
|
||||
|
||||
if (this.settings.depot) {
|
||||
await this.launchDepot();
|
||||
await delay(2000);
|
||||
|
||||
explicitRelayUrls.push(normalizeRelayUrl('ws://localhost:6090'));
|
||||
}
|
||||
|
||||
// #TODO: user should config outbox relays
|
||||
const outboxRelayUrls = normalizeRelayUrlSet(['wss://purplepag.es']);
|
||||
|
||||
// #TODO: user should config blacklist relays
|
||||
// No need to connect depot tunnel url
|
||||
const blacklistRelayUrls = this.settings.tunnelUrl.length
|
||||
? [this.settings.tunnelUrl, this.settings.tunnelUrl + '/']
|
||||
: [];
|
||||
|
||||
const cacheAdapter = new NDKCacheAdapterTauri(this.#storage);
|
||||
const ndk = new NDK({
|
||||
cacheAdapter,
|
||||
explicitRelayUrls,
|
||||
outboxRelayUrls,
|
||||
blacklistRelayUrls,
|
||||
enableOutboxModel: this.settings.outbox,
|
||||
autoConnectUserRelays: true,
|
||||
autoFetchUserMutelist: true,
|
||||
// clientName: 'Lume',
|
||||
// clientNip89: '',
|
||||
});
|
||||
|
||||
// add signer if exist
|
||||
const signer = await this.#initNostrSigner({ nsecbunker: this.settings.bunker });
|
||||
if (signer) ndk.signer = signer;
|
||||
|
||||
// connect
|
||||
await ndk.connect(3000);
|
||||
const fetcher = NostrFetcher.withCustomPool(ndkAdapter(ndk));
|
||||
|
||||
// update account's metadata
|
||||
if (this.account) {
|
||||
const user = ndk.getUser({ pubkey: this.account.pubkey });
|
||||
ndk.activeUser = user;
|
||||
|
||||
const contacts = await user.follows();
|
||||
this.account.contacts = [...contacts].map((user) => user.pubkey);
|
||||
}
|
||||
|
||||
this.relays = [...ndk.pool.relays.values()].map((relay) => relay.url);
|
||||
this.ndk = ndk;
|
||||
this.fetcher = fetcher;
|
||||
}
|
||||
|
||||
public updateNostrSigner({ signer }: { signer: NDKNip46Signer | NDKPrivateKeySigner }) {
|
||||
this.ndk.signer = signer;
|
||||
this.readyToSign = true;
|
||||
return this.ndk.signer;
|
||||
}
|
||||
|
||||
public async getAllCacheUsers() {
|
||||
const results: Array<NDKCacheUser> = await this.#storage.select(
|
||||
'SELECT * FROM ndk_users ORDER BY createdAt DESC;'
|
||||
);
|
||||
|
||||
if (!results.length) return [];
|
||||
|
||||
const users: NDKCacheUserProfile[] = results.map((item) => ({
|
||||
pubkey: item.pubkey,
|
||||
...JSON.parse(item.profile as string),
|
||||
}));
|
||||
return users;
|
||||
}
|
||||
|
||||
public async checkAccount() {
|
||||
const result: Array<{ total: string }> = await this.#storage.select(
|
||||
'SELECT COUNT(*) AS "total" FROM accounts WHERE is_active = "1" ORDER BY id DESC LIMIT 1;'
|
||||
);
|
||||
return parseInt(result[0].total);
|
||||
}
|
||||
|
||||
public async getActiveAccount() {
|
||||
const results: Array<Account> = await this.#storage.select(
|
||||
'SELECT * FROM accounts WHERE is_active = "1" ORDER BY id DESC LIMIT 1;'
|
||||
);
|
||||
|
||||
if (results.length) {
|
||||
return results[0];
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public async createAccount({
|
||||
id,
|
||||
pubkey,
|
||||
privkey,
|
||||
}: {
|
||||
id: string;
|
||||
pubkey: string;
|
||||
privkey?: string;
|
||||
}) {
|
||||
const existAccounts: Array<Account> = await this.#storage.select(
|
||||
'SELECT * FROM accounts WHERE pubkey = $1 ORDER BY id DESC LIMIT 1;',
|
||||
[pubkey]
|
||||
);
|
||||
|
||||
if (existAccounts.length) {
|
||||
await this.#storage.execute(
|
||||
"UPDATE accounts SET is_active = '1' WHERE pubkey = $1;",
|
||||
[pubkey]
|
||||
);
|
||||
} else {
|
||||
await this.#storage.execute(
|
||||
'INSERT OR IGNORE INTO accounts (id, pubkey, is_active) VALUES ($1, $2, $3);',
|
||||
[id, pubkey, 1]
|
||||
);
|
||||
|
||||
if (privkey) await this.#keyring_save(pubkey, privkey);
|
||||
}
|
||||
|
||||
const account = await this.getActiveAccount();
|
||||
this.account = account;
|
||||
this.account.contacts = [];
|
||||
|
||||
return account;
|
||||
}
|
||||
|
||||
/**
|
||||
* Save private key to OS secure storage
|
||||
* @deprecated this method will be remove in the next update
|
||||
*/
|
||||
public async createPrivkey(name: string, privkey: string) {
|
||||
return await this.#keyring_save(name, privkey);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load private key from OS secure storage
|
||||
* @deprecated this method will be remove in the next update
|
||||
*/
|
||||
public async loadPrivkey(name: string) {
|
||||
return await this.#keyring_load(name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove private key from OS secure storage
|
||||
* @deprecated this method will be remove in the next update
|
||||
*/
|
||||
public async removePrivkey(name: string) {
|
||||
return await this.#keyring_remove(name);
|
||||
}
|
||||
|
||||
public async updateAccount(column: string, value: string) {
|
||||
const insert = await this.#storage.execute(
|
||||
`UPDATE accounts SET ${column} = $1 WHERE id = $2;`,
|
||||
[value, this.account.id]
|
||||
);
|
||||
|
||||
if (insert) {
|
||||
const account = await this.getActiveAccount();
|
||||
return account;
|
||||
}
|
||||
}
|
||||
|
||||
public async getWidgets() {
|
||||
const widgets: Array<WidgetProps> = await this.#storage.select(
|
||||
'SELECT * FROM widgets WHERE account_id = $1 ORDER BY created_at DESC;',
|
||||
[this.account.id]
|
||||
);
|
||||
return widgets;
|
||||
}
|
||||
|
||||
public async createWidget(kind: number, title: string, content: string | string[]) {
|
||||
const insert = await this.#storage.execute(
|
||||
'INSERT INTO widgets (account_id, kind, title, content) VALUES ($1, $2, $3, $4);',
|
||||
[this.account.id, kind, title, content]
|
||||
);
|
||||
|
||||
if (insert) {
|
||||
const widgets: Array<WidgetProps> = await this.#storage.select(
|
||||
'SELECT * FROM widgets ORDER BY id DESC LIMIT 1;'
|
||||
);
|
||||
if (widgets.length < 1) console.error('get created widget failed');
|
||||
return widgets[0];
|
||||
} else {
|
||||
console.error('create widget failed');
|
||||
}
|
||||
}
|
||||
|
||||
public async removeWidget(id: string) {
|
||||
const res = await this.#storage.execute('DELETE FROM widgets WHERE id = $1;', [id]);
|
||||
if (res) return id;
|
||||
}
|
||||
|
||||
public async createSetting(key: string, value: string | undefined) {
|
||||
const currentSetting = await this.checkSettingValue(key);
|
||||
|
||||
if (!currentSetting) {
|
||||
return await this.#storage.execute(
|
||||
'INSERT OR IGNORE INTO settings (key, value) VALUES ($1, $2);',
|
||||
[key, value]
|
||||
);
|
||||
}
|
||||
|
||||
return await this.#storage.execute('UPDATE settings SET value = $1 WHERE key = $2;', [
|
||||
value,
|
||||
key,
|
||||
]);
|
||||
}
|
||||
|
||||
public async getAllSettings() {
|
||||
const results: { key: string; value: string }[] = await this.#storage.select(
|
||||
'SELECT * FROM settings ORDER BY id DESC;'
|
||||
);
|
||||
if (results.length < 1) return [];
|
||||
return results;
|
||||
}
|
||||
|
||||
public async checkSettingValue(key: string) {
|
||||
const results: { key: string; value: string }[] = await this.#storage.select(
|
||||
'SELECT * FROM settings WHERE key = $1 ORDER BY id DESC LIMIT 1;',
|
||||
[key]
|
||||
);
|
||||
if (!results.length) return false;
|
||||
return results[0].value;
|
||||
}
|
||||
|
||||
public async getSettingValue(key: string) {
|
||||
const results: { key: string; value: string }[] = await this.#storage.select(
|
||||
'SELECT * FROM settings WHERE key = $1 ORDER BY id DESC LIMIT 1;',
|
||||
[key]
|
||||
);
|
||||
if (!results.length) return '0';
|
||||
return results[0].value;
|
||||
}
|
||||
|
||||
public async clearCache() {
|
||||
await this.#storage.execute('DELETE FROM ndk_events;');
|
||||
await this.#storage.execute('DELETE FROM ndk_eventtags;');
|
||||
await this.#storage.execute('DELETE FROM ndk_users;');
|
||||
}
|
||||
|
||||
public async logout() {
|
||||
await this.#keyring_remove(this.account.pubkey);
|
||||
await this.#keyring_remove(`${this.account.pubkey}-nsecbunker`);
|
||||
await this.#storage.execute("UPDATE accounts SET is_active = '0' WHERE id = $1;", [
|
||||
this.account.id,
|
||||
]);
|
||||
|
||||
this.account = null;
|
||||
this.ndk.signer = null;
|
||||
}
|
||||
|
||||
public subscribe({
|
||||
filter,
|
||||
closeOnEose = false,
|
||||
@ -520,37 +143,52 @@ export class Ark {
|
||||
outbox?: boolean;
|
||||
}) {
|
||||
try {
|
||||
const user = this.ndk.getUser({ pubkey: pubkey ? pubkey : this.account.pubkey });
|
||||
const user = this.ndk.getUser({
|
||||
pubkey: pubkey ? pubkey : this.#storage.account.pubkey,
|
||||
});
|
||||
const contacts = [...(await user.follows(undefined, outbox))].map(
|
||||
(user) => user.pubkey
|
||||
);
|
||||
|
||||
if (pubkey === this.account.pubkey) this.account.contacts = contacts;
|
||||
if (pubkey === this.#storage.account.pubkey)
|
||||
this.#storage.account.contacts = contacts;
|
||||
return contacts;
|
||||
} catch (e) {
|
||||
throw new Error(e);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
public async getUserRelays({ pubkey }: { pubkey?: string }) {
|
||||
try {
|
||||
const user = this.ndk.getUser({ pubkey: pubkey ? pubkey : this.account.pubkey });
|
||||
const user = this.ndk.getUser({
|
||||
pubkey: pubkey ? pubkey : this.#storage.account.pubkey,
|
||||
});
|
||||
return await user.relayList();
|
||||
} catch (e) {
|
||||
throw new Error(e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public async newContactList({ tags }: { tags: NDKTag[] }) {
|
||||
const publish = await this.createEvent({
|
||||
kind: NDKKind.Contacts,
|
||||
tags: tags,
|
||||
});
|
||||
|
||||
if (publish) {
|
||||
this.#storage.account.contacts = tags.map((item) => item[1]);
|
||||
return publish;
|
||||
}
|
||||
}
|
||||
|
||||
public async createContact({ pubkey }: { pubkey: string }) {
|
||||
const user = this.ndk.getUser({ pubkey: this.account.pubkey });
|
||||
const user = this.ndk.getUser({ pubkey: this.#storage.account.pubkey });
|
||||
const contacts = await user.follows();
|
||||
return await user.follow(new NDKUser({ pubkey: pubkey }), contacts);
|
||||
}
|
||||
|
||||
public async deleteContact({ pubkey }: { pubkey: string }) {
|
||||
const user = this.ndk.getUser({ pubkey: this.account.pubkey });
|
||||
const user = this.ndk.getUser({ pubkey: this.#storage.account.pubkey });
|
||||
const contacts = await user.follows();
|
||||
contacts.delete(new NDKUser({ pubkey: pubkey }));
|
||||
|
||||
@ -644,7 +282,7 @@ export class Ark {
|
||||
|
||||
if (!data) {
|
||||
const relayUrls = [...this.ndk.pool.relays.values()].map((item) => item.url);
|
||||
const rawEvents = (await this.fetcher.fetchAllEvents(
|
||||
const rawEvents = (await this.#fetcher.fetchAllEvents(
|
||||
relayUrls,
|
||||
{
|
||||
kinds: [NDKKind.Text],
|
||||
@ -686,11 +324,12 @@ export class Ark {
|
||||
|
||||
public async getAllRelaysFromContacts() {
|
||||
const LIMIT = 1;
|
||||
const connectedRelays = this.ndk.pool.connectedRelays().map((item) => item.url);
|
||||
const relayMap = new Map<string, string[]>();
|
||||
const relayEvents = this.fetcher.fetchLatestEventsPerAuthor(
|
||||
const relayEvents = this.#fetcher.fetchLatestEventsPerAuthor(
|
||||
{
|
||||
authors: this.account.contacts,
|
||||
relayUrls: this.relays,
|
||||
authors: this.#storage.account.contacts,
|
||||
relayUrls: connectedRelays,
|
||||
},
|
||||
{ kinds: [NDKKind.RelayList] },
|
||||
LIMIT
|
||||
@ -725,8 +364,9 @@ export class Ark {
|
||||
}) {
|
||||
const rootIds = new Set();
|
||||
const dedupQueue = new Set();
|
||||
const connectedRelays = this.ndk.pool.connectedRelays().map((item) => item.url);
|
||||
|
||||
const events = await this.fetcher.fetchLatestEvents(this.relays, filter, limit, {
|
||||
const events = await this.#fetcher.fetchLatestEvents(connectedRelays, filter, limit, {
|
||||
asOf: pageParam === 0 ? undefined : pageParam,
|
||||
abortSignal: signal,
|
||||
});
|
||||
@ -767,7 +407,7 @@ export class Ark {
|
||||
signal?: AbortSignal;
|
||||
dedup?: boolean;
|
||||
}) {
|
||||
const events = await this.fetcher.fetchLatestEvents(
|
||||
const events = await this.#fetcher.fetchLatestEvents(
|
||||
[normalizeRelayUrl(relayUrl)],
|
||||
filter,
|
||||
limit,
|
||||
@ -856,107 +496,6 @@ export class Ark {
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return all NIP-04 messages
|
||||
* @deprecated NIP-04 will be replace by NIP-44 in the next update
|
||||
*/
|
||||
public async getAllChats() {
|
||||
const events = await this.fetcher.fetchAllEvents(
|
||||
this.relays,
|
||||
{
|
||||
kinds: [NDKKind.EncryptedDirectMessage],
|
||||
'#p': [this.account.pubkey],
|
||||
},
|
||||
{ since: 0 }
|
||||
);
|
||||
|
||||
const dedup: NDKEvent[] = Object.values(
|
||||
events.reduce((ev, { id, content, pubkey, created_at, tags }) => {
|
||||
if (ev[pubkey]) {
|
||||
if (ev[pubkey].created_at < created_at) {
|
||||
ev[pubkey] = { id, content, pubkey, created_at, tags };
|
||||
}
|
||||
} else {
|
||||
ev[pubkey] = { id, content, pubkey, created_at, tags };
|
||||
}
|
||||
return ev;
|
||||
}, {})
|
||||
);
|
||||
|
||||
return dedup;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return all NIP-04 messages by pubkey
|
||||
* @deprecated NIP-04 will be replace by NIP-44 in the next update
|
||||
*/
|
||||
public async getAllMessagesByPubkey({ pubkey }: { pubkey: string }) {
|
||||
let senderMessages: NostrEventExt<false>[] = [];
|
||||
|
||||
if (pubkey !== this.account.pubkey) {
|
||||
senderMessages = await this.fetcher.fetchAllEvents(
|
||||
this.relays,
|
||||
{
|
||||
kinds: [NDKKind.EncryptedDirectMessage],
|
||||
authors: [pubkey],
|
||||
'#p': [this.account.pubkey],
|
||||
},
|
||||
{ since: 0 }
|
||||
);
|
||||
}
|
||||
|
||||
const userMessages = await this.fetcher.fetchAllEvents(
|
||||
this.relays,
|
||||
{
|
||||
kinds: [NDKKind.EncryptedDirectMessage],
|
||||
authors: [this.account.pubkey],
|
||||
'#p': [pubkey],
|
||||
},
|
||||
{ since: 0 }
|
||||
);
|
||||
|
||||
const all = [...senderMessages, ...userMessages].sort(
|
||||
(a, b) => a.created_at - b.created_at
|
||||
);
|
||||
|
||||
return all as unknown as NDKEvent[];
|
||||
}
|
||||
|
||||
public async nip04Decrypt({ event }: { event: NDKEvent }) {
|
||||
try {
|
||||
const sender = new NDKUser({
|
||||
pubkey:
|
||||
this.account.pubkey === event.pubkey
|
||||
? event.tags.find((el) => el[0] === 'p')[1]
|
||||
: event.pubkey,
|
||||
});
|
||||
const content = await this.ndk.signer.decrypt(sender, event.content);
|
||||
|
||||
return content;
|
||||
} catch (e) {
|
||||
throw new Error(e);
|
||||
}
|
||||
}
|
||||
|
||||
public async nip04Encrypt({ content, pubkey }: { content: string; pubkey: string }) {
|
||||
try {
|
||||
const recipient = new NDKUser({ pubkey });
|
||||
const message = await this.ndk.signer.encrypt(recipient, content);
|
||||
|
||||
const event = new NDKEvent(this.ndk);
|
||||
event.content = message;
|
||||
event.kind = NDKKind.EncryptedDirectMessage;
|
||||
event.tag(recipient);
|
||||
|
||||
const publish = await event.publish();
|
||||
|
||||
if (!publish) throw new Error('Failed to send NIP-04 encrypted message');
|
||||
return { id: event.id, seens: [...publish.values()].map((item) => item.url) };
|
||||
} catch (e) {
|
||||
throw new Error(e);
|
||||
}
|
||||
}
|
||||
|
||||
public async replyTo({ content, event }: { content: string; event: NDKEvent }) {
|
||||
try {
|
||||
const replyEvent = new NDKEvent(this.ndk);
|
||||
|
67
src/libs/ark/components/note/builds/reply.tsx
Normal file
67
src/libs/ark/components/note/builds/reply.tsx
Normal file
@ -0,0 +1,67 @@
|
||||
import * as Collapsible from '@radix-ui/react-collapsible';
|
||||
import { useState } from 'react';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
import { NavArrowDownIcon } from '@shared/icons';
|
||||
import { User } from '@shared/user';
|
||||
import { NDKEventWithReplies } from '@utils/types';
|
||||
import { Note } from '..';
|
||||
|
||||
export function Reply({
|
||||
event,
|
||||
rootEvent,
|
||||
}: {
|
||||
event: NDKEventWithReplies;
|
||||
rootEvent: string;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<Collapsible.Root open={open} onOpenChange={setOpen}>
|
||||
<Note.Root>
|
||||
<Note.User pubkey={event.pubkey} time={event.created_at} className="h-14 px-3" />
|
||||
<Note.TextContent content={event.content} className="min-w-0 px-3" />
|
||||
<div className="-ml-1 flex items-center justify-between">
|
||||
{event.replies?.length > 0 ? (
|
||||
<Collapsible.Trigger asChild>
|
||||
<div className="ml-4 inline-flex h-14 items-center gap-1 font-semibold text-blue-500">
|
||||
<NavArrowDownIcon
|
||||
className={twMerge('h-3 w-3', open ? 'rotate-180 transform' : '')}
|
||||
/>
|
||||
{event.replies?.length +
|
||||
' ' +
|
||||
(event.replies?.length === 1 ? 'reply' : 'replies')}
|
||||
</div>
|
||||
</Collapsible.Trigger>
|
||||
) : null}
|
||||
<div className="inline-flex items-center gap-10">
|
||||
<Note.Reply eventId={event.id} rootEventId={rootEvent} />
|
||||
<Note.Reaction event={event} />
|
||||
<Note.Repost event={event} />
|
||||
<Note.Zap event={event} />
|
||||
</div>
|
||||
</div>
|
||||
<div className={twMerge('px-3', open ? 'pb-3' : '')}>
|
||||
{event.replies?.length > 0 ? (
|
||||
<Collapsible.Content>
|
||||
{event.replies?.map((childEvent) => (
|
||||
<Note.Root key={childEvent.id}>
|
||||
<User pubkey={event.pubkey} time={event.created_at} />
|
||||
<Note.TextContent content={event.content} className="min-w-0 px-3" />
|
||||
<div className="-ml-1 flex h-14 items-center justify-between px-3">
|
||||
<Note.Pin eventId={event.id} />
|
||||
<div className="inline-flex items-center gap-10">
|
||||
<Note.Reply eventId={event.id} rootEventId={rootEvent} />
|
||||
<Note.Reaction event={event} />
|
||||
<Note.Repost event={event} />
|
||||
<Note.Zap event={event} />
|
||||
</div>
|
||||
</div>
|
||||
</Note.Root>
|
||||
))}
|
||||
</Collapsible.Content>
|
||||
) : null}
|
||||
</div>
|
||||
</Note.Root>
|
||||
</Collapsible.Root>
|
||||
);
|
||||
}
|
@ -1,17 +1,9 @@
|
||||
import { NDKEvent, NDKKind, NostrEvent } from '@nostr-dev-kit/ndk';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { memo } from 'react';
|
||||
import { useArk } from '@libs/ark';
|
||||
import {
|
||||
MemoizedArticleKind,
|
||||
MemoizedFileKind,
|
||||
MemoizedTextKind,
|
||||
NoteActions,
|
||||
NoteSkeleton,
|
||||
} from '@shared/notes';
|
||||
import { User } from '@shared/user';
|
||||
import { useArk } from '@libs/ark/provider';
|
||||
import { Note } from '..';
|
||||
|
||||
export function Repost({ event }: { event: NDKEvent }) {
|
||||
export function RepostNote({ event }: { event: NDKEvent }) {
|
||||
const ark = useArk();
|
||||
const {
|
||||
isLoading,
|
||||
@ -25,7 +17,6 @@ export function Repost({ event }: { event: NDKEvent }) {
|
||||
const embed = JSON.parse(event.content) as NostrEvent;
|
||||
return new NDKEvent(ark.ndk, embed);
|
||||
}
|
||||
|
||||
const id = event.tags.find((el) => el[0] === 'e')[1];
|
||||
return await ark.getEventById({ id });
|
||||
} catch {
|
||||
@ -39,29 +30,22 @@ export function Repost({ event }: { event: NDKEvent }) {
|
||||
if (!repostEvent) return null;
|
||||
switch (repostEvent.kind) {
|
||||
case NDKKind.Text:
|
||||
return <MemoizedTextKind content={repostEvent.content} />;
|
||||
return <Note.TextContent content={repostEvent.content} />;
|
||||
case 1063:
|
||||
return <MemoizedFileKind tags={repostEvent.tags} />;
|
||||
case NDKKind.Article:
|
||||
return <MemoizedArticleKind id={repostEvent.id} tags={repostEvent.tags} />;
|
||||
return <Note.MediaContent tags={repostEvent.tags} />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="w-full px-3 pb-3">
|
||||
<NoteSkeleton />
|
||||
</div>
|
||||
);
|
||||
return <div className="w-full px-3 pb-3"></div>;
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
<div className="my-3 h-min w-full px-3">
|
||||
<div className="relative flex flex-col gap-2 overflow-hidden rounded-xl bg-neutral-50 pt-3 dark:bg-neutral-950">
|
||||
<User pubkey={event.pubkey} time={event.created_at} variant="repost" />
|
||||
<div className="relative flex flex-col gap-2">
|
||||
<div className="px-3">
|
||||
<p>Failed to load event</p>
|
||||
@ -73,21 +57,26 @@ export function Repost({ event }: { event: NDKEvent }) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="my-3 h-min w-full px-3">
|
||||
<div className="relative flex flex-col gap-2 overflow-hidden rounded-xl bg-neutral-50 pt-3 dark:bg-neutral-950">
|
||||
<User pubkey={event.pubkey} time={event.created_at} variant="repost" />
|
||||
<div className="relative flex flex-col gap-2">
|
||||
<User
|
||||
pubkey={repostEvent.pubkey}
|
||||
time={repostEvent.created_at}
|
||||
eventId={repostEvent.id}
|
||||
/>
|
||||
{renderContentByKind()}
|
||||
<NoteActions event={repostEvent} />
|
||||
<Note.Root>
|
||||
<Note.User
|
||||
pubkey={event.pubkey}
|
||||
time={event.created_at}
|
||||
variant="repost"
|
||||
className="h-14"
|
||||
/>
|
||||
<div className="relative flex flex-col gap-2 px-3">
|
||||
<Note.User pubkey={repostEvent.pubkey} time={repostEvent.created_at} />
|
||||
{renderContentByKind()}
|
||||
<div className="flex h-14 items-center justify-between">
|
||||
<Note.Pin eventId={event.id} />
|
||||
<div className="inline-flex items-center gap-10">
|
||||
<Note.Reply eventId={repostEvent.id} />
|
||||
<Note.Reaction event={repostEvent} />
|
||||
<Note.Repost event={repostEvent} />
|
||||
<Note.Zap event={repostEvent} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Note.Root>
|
||||
);
|
||||
}
|
||||
|
||||
export const MemoizedRepost = memo(Repost);
|
24
src/libs/ark/components/note/builds/skeleton.tsx
Normal file
24
src/libs/ark/components/note/builds/skeleton.tsx
Normal file
@ -0,0 +1,24 @@
|
||||
import { Note } from '..';
|
||||
|
||||
export function NoteSkeleton() {
|
||||
return (
|
||||
<Note.Root>
|
||||
<div className="flex h-min flex-col p-3">
|
||||
<div className="flex items-start gap-2">
|
||||
<div className="relative h-10 w-10 shrink-0 animate-pulse overflow-hidden rounded-lg bg-neutral-400 dark:bg-neutral-600" />
|
||||
<div className="h-6 w-full">
|
||||
<div className="h-4 w-24 animate-pulse rounded bg-neutral-300 dark:bg-neutral-700" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="-mt-4 flex gap-3">
|
||||
<div className="w-10 shrink-0" />
|
||||
<div className="flex w-full flex-col gap-1">
|
||||
<div className="h-3 w-2/3 animate-pulse rounded bg-neutral-300 dark:bg-neutral-700" />
|
||||
<div className="h-3 w-2/3 animate-pulse rounded bg-neutral-300 dark:bg-neutral-700" />
|
||||
<div className="h-3 w-1/2 animate-pulse rounded bg-neutral-300 dark:bg-neutral-700" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Note.Root>
|
||||
);
|
||||
}
|
25
src/libs/ark/components/note/builds/text.tsx
Normal file
25
src/libs/ark/components/note/builds/text.tsx
Normal file
@ -0,0 +1,25 @@
|
||||
import { NDKEvent } from '@nostr-dev-kit/ndk';
|
||||
import { useArk } from '@libs/ark/provider';
|
||||
import { Note } from '..';
|
||||
|
||||
export function TextNote({ event }: { event: NDKEvent }) {
|
||||
const ark = useArk();
|
||||
const thread = ark.getEventThread({ tags: event.tags });
|
||||
|
||||
return (
|
||||
<Note.Root>
|
||||
<Note.User pubkey={event.pubkey} time={event.created_at} className="h-14 px-3" />
|
||||
<Note.Thread thread={thread} className="mb-2" />
|
||||
<Note.TextContent content={event.content} className="min-w-0 px-3" />
|
||||
<div className="flex h-14 items-center justify-between px-3">
|
||||
<Note.Pin eventId={event.id} />
|
||||
<div className="inline-flex items-center gap-10">
|
||||
<Note.Reply eventId={event.id} rootEventId={thread?.rootEventId} />
|
||||
<Note.Reaction event={event} />
|
||||
<Note.Repost event={event} />
|
||||
<Note.Zap event={event} />
|
||||
</div>
|
||||
</div>
|
||||
</Note.Root>
|
||||
);
|
||||
}
|
@ -1,14 +1,24 @@
|
||||
import * as Tooltip from '@radix-ui/react-tooltip';
|
||||
import { useWidget } from '@libs/ark';
|
||||
import { PinIcon } from '@shared/icons';
|
||||
import { WIDGET_KIND } from '@utils/constants';
|
||||
|
||||
export function NotePin({ eventId }: { eventId: string }) {
|
||||
const { addWidget } = useWidget();
|
||||
|
||||
export function NotePin({ action }: { action: () => void }) {
|
||||
return (
|
||||
<Tooltip.Provider>
|
||||
<Tooltip.Root delayDuration={150}>
|
||||
<Tooltip.Trigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={action}
|
||||
onClick={() =>
|
||||
addWidget.mutate({
|
||||
kind: WIDGET_KIND.thread,
|
||||
title: 'Thread',
|
||||
content: eventId,
|
||||
})
|
||||
}
|
||||
className="inline-flex h-7 w-max items-center justify-center gap-2 rounded-full bg-neutral-100 px-2 text-sm font-medium dark:bg-neutral-900"
|
||||
>
|
||||
<PinIcon className="size-4" />
|
43
src/libs/ark/components/note/buttons/reply.tsx
Normal file
43
src/libs/ark/components/note/buttons/reply.tsx
Normal file
@ -0,0 +1,43 @@
|
||||
import * as Tooltip from '@radix-ui/react-tooltip';
|
||||
import { createSearchParams, useNavigate } from 'react-router-dom';
|
||||
import { ReplyIcon } from '@shared/icons';
|
||||
|
||||
export function NoteReply({
|
||||
eventId,
|
||||
rootEventId,
|
||||
}: {
|
||||
eventId: string;
|
||||
rootEventId?: string;
|
||||
}) {
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
<Tooltip.Provider>
|
||||
<Tooltip.Root delayDuration={150}>
|
||||
<Tooltip.Trigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
navigate({
|
||||
pathname: '/new/',
|
||||
search: createSearchParams({
|
||||
replyTo: eventId,
|
||||
rootReplyTo: rootEventId,
|
||||
}).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" />
|
||||
</button>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Portal>
|
||||
<Tooltip.Content className="-left-10 inline-flex h-7 select-none items-center justify-center rounded-md bg-neutral-200 px-3.5 text-sm text-neutral-900 will-change-[transform,opacity] data-[state=delayed-open]:data-[side=bottom]:animate-slideUpAndFade data-[state=delayed-open]:data-[side=left]:animate-slideRightAndFade data-[state=delayed-open]:data-[side=right]:animate-slideLeftAndFade data-[state=delayed-open]:data-[side=top]:animate-slideDownAndFade dark:bg-neutral-800 dark:text-neutral-100">
|
||||
Quick reply
|
||||
<Tooltip.Arrow className="fill-neutral-200 dark:fill-neutral-800" />
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Portal>
|
||||
</Tooltip.Root>
|
||||
</Tooltip.Provider>
|
||||
);
|
||||
}
|
@ -8,7 +8,7 @@ import { QRCodeSVG } from 'qrcode.react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import CurrencyInput from 'react-currency-input-field';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useArk } from '@libs/ark';
|
||||
import { useArk, useStorage } from '@libs/ark';
|
||||
import { CancelIcon, ZapIcon } from '@shared/icons';
|
||||
import { compactNumber, displayNpub } from '@utils/formater';
|
||||
import { useProfile } from '@utils/hooks/useProfile';
|
||||
@ -26,12 +26,13 @@ export function NoteZap({ event }: { event: NDKEvent }) {
|
||||
const { user } = useProfile(event.pubkey);
|
||||
|
||||
const ark = useArk();
|
||||
const storage = useStorage();
|
||||
const nwc = useRef(null);
|
||||
const navigate = useNavigate();
|
||||
|
||||
const createZapRequest = async () => {
|
||||
try {
|
||||
if (!ark.readyToSign) return navigate('/new/privkey');
|
||||
if (!ark.ndk.signer) return navigate('/new/privkey');
|
||||
|
||||
const zapAmount = parseInt(amount) * 1000;
|
||||
const res = await event.zap(zapAmount, zapMessage);
|
||||
@ -82,7 +83,7 @@ export function NoteZap({ event }: { event: NDKEvent }) {
|
||||
useEffect(() => {
|
||||
async function getWalletConnectURL() {
|
||||
const uri: string = await invoke('secure_load', {
|
||||
key: `${ark.account.pubkey}-nwc`,
|
||||
key: `${storage.account.pubkey}-nwc`,
|
||||
});
|
||||
if (uri) setWalletConnectURL(uri);
|
||||
}
|
@ -1,27 +1,8 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useArk } from '@libs/ark';
|
||||
import { useEvent } from '@libs/ark';
|
||||
import { NoteChildUser } from './childUser';
|
||||
|
||||
export function NoteChild({ eventId, isRoot }: { eventId: string; isRoot?: boolean }) {
|
||||
const ark = useArk();
|
||||
const { isLoading, isError, data } = useQuery({
|
||||
queryKey: ['event', eventId],
|
||||
queryFn: async () => {
|
||||
// get event from relay
|
||||
const event = await ark.getEventById({ id: eventId });
|
||||
|
||||
if (!event)
|
||||
throw new Error(
|
||||
`Cannot get event with ${eventId}, will be retry after 10 seconds`
|
||||
);
|
||||
|
||||
return event;
|
||||
},
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnMount: false,
|
||||
refetchOnReconnect: false,
|
||||
retry: 2,
|
||||
});
|
||||
const { isLoading, isError, data } = useEvent(eventId);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
|
@ -1,39 +1,17 @@
|
||||
import * as Avatar from '@radix-ui/react-avatar';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { minidenticon } from 'minidenticons';
|
||||
import { useMemo } from 'react';
|
||||
import { useArk } from '@libs/ark';
|
||||
import { useProfile } from '@libs/ark';
|
||||
import { displayNpub } from '@utils/formater';
|
||||
|
||||
export function NoteChildUser({ pubkey, subtext }: { pubkey: string; subtext: string }) {
|
||||
const ark = useArk();
|
||||
const fallbackName = useMemo(() => displayNpub(pubkey, 16), [pubkey]);
|
||||
const fallbackAvatar = useMemo(
|
||||
() => 'data:image/svg+xml;utf8,' + encodeURIComponent(minidenticon(pubkey, 90, 50)),
|
||||
[pubkey]
|
||||
);
|
||||
|
||||
const { isLoading, data: user } = useQuery({
|
||||
queryKey: ['user', pubkey],
|
||||
queryFn: async () => {
|
||||
try {
|
||||
const profile = await ark.getUserProfile({ pubkey });
|
||||
|
||||
if (!profile)
|
||||
throw new Error(
|
||||
`Cannot get metadata for ${pubkey}, will be retry after 10 seconds`
|
||||
);
|
||||
|
||||
return profile;
|
||||
} catch (e) {
|
||||
throw new Error(e);
|
||||
}
|
||||
},
|
||||
refetchOnMount: false,
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnReconnect: false,
|
||||
retry: 2,
|
||||
});
|
||||
const { isLoading, user } = useProfile(pubkey);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
|
@ -1,23 +1,42 @@
|
||||
import { NotePin } from './buttons/pin';
|
||||
import { NoteReaction } from './buttons/reaction';
|
||||
import { NoteReply } from './buttons/reply';
|
||||
import { NoteRepost } from './buttons/repost';
|
||||
import { NoteZap } from './buttons/zap';
|
||||
import { NoteChild } from './child';
|
||||
import { NoteKind } from './kind';
|
||||
import { NoteArticleContent } from './kinds/article';
|
||||
import { NoteMediaContent } from './kinds/media';
|
||||
import { NoteTextContent } from './kinds/text';
|
||||
import { NoteMenu } from './menu';
|
||||
import { NotePin } from './pin';
|
||||
import { NoteReaction } from './reaction';
|
||||
import { NoteReply } from './reply';
|
||||
import { NoteRepost } from './repost';
|
||||
import { NoteReplies } from './reply';
|
||||
import { NoteRoot } from './root';
|
||||
import { NoteThread } from './thread';
|
||||
import { NoteUser } from './user';
|
||||
import { NoteZap } from './zap';
|
||||
|
||||
export const Note = {
|
||||
Root: NoteRoot,
|
||||
User: NoteUser,
|
||||
Menu: NoteMenu,
|
||||
Kind: NoteKind,
|
||||
Reply: NoteReply,
|
||||
Repost: NoteRepost,
|
||||
Reaction: NoteReaction,
|
||||
Zap: NoteZap,
|
||||
Pin: NotePin,
|
||||
Child: NoteChild,
|
||||
Thread: NoteThread,
|
||||
TextContent: NoteTextContent,
|
||||
MediaContent: NoteMediaContent,
|
||||
ArticleContent: NoteArticleContent,
|
||||
Replies: NoteReplies,
|
||||
};
|
||||
|
||||
export * from './builds/text';
|
||||
export * from './builds/repost';
|
||||
export * from './builds/skeleton';
|
||||
export * from './preview/image';
|
||||
export * from './preview/link';
|
||||
export * from './preview/video';
|
||||
export * from './mentions/note';
|
||||
export * from './mentions/user';
|
||||
export * from './mentions/hashtag';
|
||||
export * from './mentions/invoice';
|
||||
|
@ -1,8 +1,13 @@
|
||||
import { NDKTag } from '@nostr-dev-kit/ndk';
|
||||
import { memo } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
export function ArticleKind({ id, tags }: { id: string; tags: NDKTag[] }) {
|
||||
export function NoteArticleContent({
|
||||
eventId,
|
||||
tags,
|
||||
}: {
|
||||
eventId: string;
|
||||
tags: NDKTag[];
|
||||
}) {
|
||||
const getMetadata = () => {
|
||||
const title = tags.find((tag) => tag[0] === 'title')?.[1];
|
||||
const image = tags.find((tag) => tag[0] === 'image')?.[1];
|
||||
@ -26,7 +31,7 @@ export function ArticleKind({ id, tags }: { id: string; tags: NDKTag[] }) {
|
||||
|
||||
return (
|
||||
<Link
|
||||
to={`/notes/article/${id}`}
|
||||
to={`/events/${eventId}`}
|
||||
preventScrollReset={true}
|
||||
className="flex w-full flex-col rounded-lg border border-neutral-200 bg-neutral-100 dark:border-neutral-800 dark:bg-neutral-900"
|
||||
>
|
||||
@ -56,5 +61,3 @@ export function ArticleKind({ id, tags }: { id: string; tags: NDKTag[] }) {
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
export const MemoizedArticleKind = memo(ArticleKind);
|
@ -6,12 +6,18 @@ import {
|
||||
DefaultVideoLayout,
|
||||
defaultLayoutIcons,
|
||||
} from '@vidstack/react/player/layouts/default';
|
||||
import { memo } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
import { DownloadIcon } from '@shared/icons';
|
||||
import { fileType } from '@utils/nip94';
|
||||
|
||||
export function FileKind({ tags }: { tags: NDKTag[] }) {
|
||||
export function NoteMediaContent({
|
||||
tags,
|
||||
className,
|
||||
}: {
|
||||
tags: NDKTag[];
|
||||
className?: string;
|
||||
}) {
|
||||
const url = tags.find((el) => el[0] === 'url')[1];
|
||||
const type = fileType(url);
|
||||
|
||||
@ -23,7 +29,7 @@ export function FileKind({ tags }: { tags: NDKTag[] }) {
|
||||
|
||||
if (type === 'image') {
|
||||
return (
|
||||
<div key={url} className="group relative">
|
||||
<div key={url} className={twMerge('group relative', className)}>
|
||||
<img
|
||||
src={url}
|
||||
alt={url}
|
||||
@ -45,28 +51,30 @@ export function FileKind({ tags }: { tags: NDKTag[] }) {
|
||||
|
||||
if (type === 'video') {
|
||||
return (
|
||||
<MediaPlayer
|
||||
src={url}
|
||||
className="w-full overflow-hidden rounded-lg"
|
||||
aspectRatio="16/9"
|
||||
load="visible"
|
||||
>
|
||||
<MediaProvider />
|
||||
<DefaultVideoLayout icons={defaultLayoutIcons} />
|
||||
</MediaPlayer>
|
||||
<div className={className}>
|
||||
<MediaPlayer
|
||||
src={url}
|
||||
className="w-full overflow-hidden rounded-lg"
|
||||
aspectRatio="16/9"
|
||||
load="visible"
|
||||
>
|
||||
<MediaProvider />
|
||||
<DefaultVideoLayout icons={defaultLayoutIcons} />
|
||||
</MediaPlayer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Link
|
||||
to={url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-blue-500 hover:text-blue-600"
|
||||
>
|
||||
{url}
|
||||
</Link>
|
||||
<div className={className}>
|
||||
<Link
|
||||
to={url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-blue-500 hover:text-blue-600"
|
||||
>
|
||||
{url}
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const MemoizedFileKind = memo(FileKind);
|
@ -1,7 +1,7 @@
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
import { useRichContent } from '@utils/hooks/useRichContent';
|
||||
import { useRichContent } from '@libs/ark';
|
||||
|
||||
export function NoteKind({
|
||||
export function NoteTextContent({
|
||||
content,
|
||||
className,
|
||||
}: {
|
||||
@ -13,7 +13,7 @@ export function NoteKind({
|
||||
return (
|
||||
<div
|
||||
className={twMerge(
|
||||
'break-p select-text whitespace-pre-line leading-normal',
|
||||
'break-p select-text whitespace-pre-line text-balance leading-normal',
|
||||
className
|
||||
)}
|
||||
>
|
@ -1,5 +1,5 @@
|
||||
import { useWidget } from '@libs/ark/hooks/useWidget';
|
||||
import { WIDGET_KIND } from '@utils/constants';
|
||||
import { useWidget } from '@utils/hooks/useWidget';
|
||||
|
||||
export function Hashtag({ tag }: { tag: string }) {
|
||||
const { addWidget } = useWidget();
|
63
src/libs/ark/components/note/mentions/note.tsx
Normal file
63
src/libs/ark/components/note/mentions/note.tsx
Normal file
@ -0,0 +1,63 @@
|
||||
import { NDKEvent, NDKKind } from '@nostr-dev-kit/ndk';
|
||||
import { memo } from 'react';
|
||||
import { useEvent, useWidget } from '@libs/ark';
|
||||
import { WIDGET_KIND } from '@utils/constants';
|
||||
import { Note } from '..';
|
||||
|
||||
export const MentionNote = memo(function MentionNote({ eventId }: { eventId: string }) {
|
||||
const { isLoading, isError, data } = useEvent(eventId);
|
||||
const { addWidget } = useWidget();
|
||||
|
||||
const renderKind = (event: NDKEvent) => {
|
||||
switch (event.kind) {
|
||||
case NDKKind.Text:
|
||||
return <Note.TextContent content={event.content} />;
|
||||
case NDKKind.Article:
|
||||
return <Note.ArticleContent eventId={event.id} tags={event.tags} />;
|
||||
case 1063:
|
||||
return <Note.MediaContent tags={event.tags} />;
|
||||
default:
|
||||
return <Note.TextContent content={event.content} />;
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="w-full cursor-default rounded-lg bg-neutral-100 p-3 dark:bg-neutral-900">
|
||||
Loading
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
<div className="w-full cursor-default rounded-lg bg-neutral-100 p-3 dark:bg-neutral-900">
|
||||
Failed to fetch event
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Note.Root className="my-2 flex w-full cursor-default flex-col gap-1 rounded-lg bg-neutral-100 dark:bg-neutral-900">
|
||||
<div className="mt-3 px-3">
|
||||
<Note.User pubkey={data.pubkey} time={data.created_at} variant="mention" />
|
||||
</div>
|
||||
<div className="mt-1 px-3 pb-3">
|
||||
{renderKind(data)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
addWidget.mutate({
|
||||
kind: WIDGET_KIND.thread,
|
||||
title: 'Thread',
|
||||
content: data.id,
|
||||
})
|
||||
}
|
||||
className="mt-2 text-blue-500 hover:text-blue-600"
|
||||
>
|
||||
Show more
|
||||
</button>
|
||||
</div>
|
||||
</Note.Root>
|
||||
);
|
||||
});
|
@ -1,7 +1,6 @@
|
||||
import { memo } from 'react';
|
||||
import { useProfile, useWidget } from '@libs/ark';
|
||||
import { WIDGET_KIND } from '@utils/constants';
|
||||
import { useProfile } from '@utils/hooks/useProfile';
|
||||
import { useWidget } from '@utils/hooks/useWidget';
|
||||
|
||||
export const MentionUser = memo(function MentionUser({ pubkey }: { pubkey: string }) {
|
||||
const { user } = useProfile(pubkey);
|
@ -1,43 +1,67 @@
|
||||
import * as Tooltip from '@radix-ui/react-tooltip';
|
||||
import { createSearchParams, useNavigate } from 'react-router-dom';
|
||||
import { ReplyIcon } from '@shared/icons';
|
||||
import { NDKSubscription } from '@nostr-dev-kit/ndk';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useArk } from '@libs/ark';
|
||||
import { LoaderIcon } from '@shared/icons';
|
||||
import { NDKEventWithReplies } from '@utils/types';
|
||||
import { Reply } from './builds/reply';
|
||||
|
||||
export function NoteReply({
|
||||
eventId,
|
||||
rootEventId,
|
||||
}: {
|
||||
eventId: string;
|
||||
rootEventId?: string;
|
||||
}) {
|
||||
const navigate = useNavigate();
|
||||
export function NoteReplies({ eventId }: { eventId: string }) {
|
||||
const ark = useArk();
|
||||
const [data, setData] = useState<null | NDKEventWithReplies[]>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let sub: NDKSubscription;
|
||||
let isCancelled = false;
|
||||
|
||||
async function fetchRepliesAndSub() {
|
||||
const events = await ark.getThreads({ id: eventId });
|
||||
if (!isCancelled) {
|
||||
setData(events);
|
||||
}
|
||||
// subscribe for new replies
|
||||
sub = ark.subscribe({
|
||||
filter: {
|
||||
'#e': [eventId],
|
||||
since: Math.floor(Date.now() / 1000),
|
||||
},
|
||||
closeOnEose: false,
|
||||
cb: (event: NDKEventWithReplies) => setData((prev) => [event, ...prev]),
|
||||
});
|
||||
}
|
||||
|
||||
fetchRepliesAndSub();
|
||||
|
||||
return () => {
|
||||
isCancelled = true;
|
||||
if (sub) sub.stop();
|
||||
};
|
||||
}, [eventId]);
|
||||
|
||||
if (!data) {
|
||||
return (
|
||||
<div className="mt-3">
|
||||
<div className="flex h-16 items-center justify-center rounded-xl bg-neutral-50 p-3 dark:bg-neutral-950">
|
||||
<LoaderIcon className="h-5 w-5 animate-spin" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip.Provider>
|
||||
<Tooltip.Root delayDuration={150}>
|
||||
<Tooltip.Trigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
navigate({
|
||||
pathname: '/new/',
|
||||
search: createSearchParams({
|
||||
replyTo: eventId,
|
||||
rootReplyTo: rootEventId,
|
||||
}).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" />
|
||||
</button>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Portal>
|
||||
<Tooltip.Content className="-left-10 inline-flex h-7 select-none items-center justify-center rounded-md bg-neutral-200 px-3.5 text-sm text-neutral-900 will-change-[transform,opacity] data-[state=delayed-open]:data-[side=bottom]:animate-slideUpAndFade data-[state=delayed-open]:data-[side=left]:animate-slideRightAndFade data-[state=delayed-open]:data-[side=right]:animate-slideLeftAndFade data-[state=delayed-open]:data-[side=top]:animate-slideDownAndFade dark:bg-neutral-800 dark:text-neutral-100">
|
||||
Quick reply
|
||||
<Tooltip.Arrow className="fill-neutral-200 dark:fill-neutral-800" />
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Portal>
|
||||
</Tooltip.Root>
|
||||
</Tooltip.Provider>
|
||||
<div className="mt-3 flex flex-col gap-5">
|
||||
<h3 className="font-semibold">Replies</h3>
|
||||
{data?.length === 0 ? (
|
||||
<div className="mt-2 flex w-full items-center justify-center">
|
||||
<div className="flex flex-col items-center justify-center gap-2 py-6">
|
||||
<h3 className="text-3xl">👋</h3>
|
||||
<p className="leading-none text-neutral-600 dark:text-neutral-400">
|
||||
Be the first to Reply!
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
data.map((event) => <Reply key={event.id} event={event} rootEvent={eventId} />)
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@ -9,10 +9,13 @@ export function NoteRoot({
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className={twMerge('h-min w-full p-3', className)}>
|
||||
<div className="relative flex flex-col overflow-hidden rounded-xl bg-neutral-50 dark:bg-neutral-950">
|
||||
{children}
|
||||
</div>
|
||||
<div
|
||||
className={twMerge(
|
||||
'mt-3 flex h-min w-full flex-col overflow-hidden rounded-xl bg-neutral-50 px-3 dark:bg-neutral-950',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
38
src/libs/ark/components/note/thread.tsx
Normal file
38
src/libs/ark/components/note/thread.tsx
Normal file
@ -0,0 +1,38 @@
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
import { useWidget } from '@libs/ark';
|
||||
import { WIDGET_KIND } from '@utils/constants';
|
||||
import { Note } from '.';
|
||||
|
||||
export function NoteThread({
|
||||
thread,
|
||||
className,
|
||||
}: {
|
||||
thread: { rootEventId: string; replyEventId: string };
|
||||
className?: string;
|
||||
}) {
|
||||
const { addWidget } = useWidget();
|
||||
|
||||
if (!thread) return null;
|
||||
|
||||
return (
|
||||
<div className={twMerge('w-full px-3', className)}>
|
||||
<div className="flex h-min w-full flex-col gap-3 rounded-lg bg-neutral-100 p-3 dark:bg-neutral-900">
|
||||
{thread.rootEventId ? <Note.Child eventId={thread.rootEventId} isRoot /> : null}
|
||||
{thread.replyEventId ? <Note.Child eventId={thread.replyEventId} /> : null}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
addWidget.mutate({
|
||||
kind: WIDGET_KIND.thread,
|
||||
title: 'Thread',
|
||||
content: thread.rootEventId,
|
||||
})
|
||||
}
|
||||
className="self-start text-blue-500 hover:text-blue-600"
|
||||
>
|
||||
Show full thread
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
@ -1,9 +1,8 @@
|
||||
import * as Avatar from '@radix-ui/react-avatar';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { minidenticon } from 'minidenticons';
|
||||
import { useMemo } from 'react';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
import { useArk } from '@libs/ark';
|
||||
import { useProfile } from '@libs/ark';
|
||||
import { RepostIcon } from '@shared/icons';
|
||||
import { displayNpub, formatCreatedAt } from '@utils/formater';
|
||||
|
||||
@ -15,10 +14,9 @@ export function NoteUser({
|
||||
}: {
|
||||
pubkey: string;
|
||||
time: number;
|
||||
variant?: 'text' | 'repost';
|
||||
variant?: 'text' | 'repost' | 'mention';
|
||||
className?: string;
|
||||
}) {
|
||||
const ark = useArk();
|
||||
const createdAt = useMemo(() => formatCreatedAt(time), [time]);
|
||||
const fallbackName = useMemo(() => displayNpub(pubkey, 16), [pubkey]);
|
||||
const fallbackAvatar = useMemo(
|
||||
@ -26,27 +24,58 @@ export function NoteUser({
|
||||
[pubkey]
|
||||
);
|
||||
|
||||
const { isLoading, data: user } = useQuery({
|
||||
queryKey: ['user', pubkey],
|
||||
queryFn: async () => {
|
||||
try {
|
||||
const profile = await ark.getUserProfile({ pubkey });
|
||||
const { isLoading, user } = useProfile(pubkey);
|
||||
|
||||
if (!profile)
|
||||
throw new Error(
|
||||
`Cannot get metadata for ${pubkey}, will be retry after 10 seconds`
|
||||
);
|
||||
if (variant === 'mention') {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<Avatar.Root className="shrink-0">
|
||||
<Avatar.Image
|
||||
src={fallbackAvatar}
|
||||
alt={pubkey}
|
||||
className="h-6 w-6 rounded-md bg-black dark:bg-white"
|
||||
/>
|
||||
</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">
|
||||
{fallbackName}
|
||||
</h5>
|
||||
<span className="text-neutral-600 dark:text-neutral-400">·</span>
|
||||
<span className="text-neutral-600 dark:text-neutral-400">{createdAt}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return profile;
|
||||
} catch (e) {
|
||||
throw new Error(e);
|
||||
}
|
||||
},
|
||||
refetchOnMount: false,
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnReconnect: false,
|
||||
retry: 2,
|
||||
});
|
||||
return (
|
||||
<div className="flex h-6 items-center gap-2">
|
||||
<Avatar.Root className="shrink-0">
|
||||
<Avatar.Image
|
||||
src={user?.picture || user?.image}
|
||||
alt={pubkey}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
className="h-6 w-6 rounded-md"
|
||||
/>
|
||||
<Avatar.Fallback delayMs={300}>
|
||||
<img
|
||||
src={fallbackAvatar}
|
||||
alt={pubkey}
|
||||
className="h-6 w-6 rounded-md bg-black dark:bg-white"
|
||||
/>
|
||||
</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">
|
||||
{user?.name || user?.display_name || user?.displayName || fallbackName}
|
||||
</h5>
|
||||
<span className="text-neutral-600 dark:text-neutral-400">·</span>
|
||||
<span className="text-neutral-600 dark:text-neutral-400">{createdAt}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (variant === 'repost') {
|
||||
if (isLoading) {
|
||||
|
@ -1,6 +1,7 @@
|
||||
import * as DropdownMenu from '@radix-ui/react-dropdown-menu';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { ReactNode } from 'react';
|
||||
import { useWidget } from '@libs/ark';
|
||||
import {
|
||||
ArrowLeftIcon,
|
||||
ArrowRightIcon,
|
||||
@ -9,7 +10,6 @@ import {
|
||||
ThreadIcon,
|
||||
TrashIcon,
|
||||
} from '@shared/icons';
|
||||
import { useWidget } from '@utils/hooks/useWidget';
|
||||
|
||||
export function WidgetHeader({
|
||||
id,
|
||||
|
@ -1,9 +1,11 @@
|
||||
import { WidgetContent } from './content';
|
||||
import { WidgetHeader } from './header';
|
||||
import { WidgetLive } from './live';
|
||||
import { WidgetRoot } from './root';
|
||||
|
||||
export const Widget = {
|
||||
Root: WidgetRoot,
|
||||
Live: WidgetLive,
|
||||
Header: WidgetHeader,
|
||||
Content: WidgetContent,
|
||||
};
|
||||
|
42
src/libs/ark/components/widget/live.tsx
Normal file
42
src/libs/ark/components/widget/live.tsx
Normal file
@ -0,0 +1,42 @@
|
||||
import { NDKEvent, NDKFilter } from '@nostr-dev-kit/ndk';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useArk } from '@libs/ark/provider';
|
||||
import { ChevronUpIcon } from '@shared/icons';
|
||||
|
||||
export function WidgetLive({
|
||||
filter,
|
||||
onClick,
|
||||
}: {
|
||||
filter: NDKFilter;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
const ark = useArk();
|
||||
const [events, setEvents] = useState<NDKEvent[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
const sub = ark.subscribe({
|
||||
filter,
|
||||
closeOnEose: false,
|
||||
cb: (event: NDKEvent) => setEvents((prev) => [...prev, event]),
|
||||
});
|
||||
|
||||
return () => {
|
||||
if (sub) sub.stop();
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (!events.length) return null;
|
||||
|
||||
return (
|
||||
<div className="absolute left-0 top-11 z-50 flex h-11 w-full items-center justify-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className="inline-flex h-9 w-max items-center justify-center gap-1 rounded-full bg-blue-500 px-2.5 text-sm font-semibold text-white hover:bg-blue-600"
|
||||
>
|
||||
<ChevronUpIcon className="h-4 w-4" />
|
||||
{events.length} {events.length === 1 ? 'event' : 'events'}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
@ -1,24 +1,14 @@
|
||||
import { NDKEvent, NostrEvent } from '@nostr-dev-kit/ndk';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useArk } from '@libs/ark';
|
||||
|
||||
export function useEvent(id: undefined | string, embed?: undefined | string) {
|
||||
export function useEvent(id: string) {
|
||||
const ark = useArk();
|
||||
const { status, isFetching, isError, data } = useQuery({
|
||||
const { status, isLoading, isError, data } = useQuery({
|
||||
queryKey: ['event', id],
|
||||
queryFn: async () => {
|
||||
// return embed event (nostr.band api)
|
||||
if (embed) {
|
||||
const embedEvent: NostrEvent = JSON.parse(embed);
|
||||
return new NDKEvent(ark.ndk, embedEvent);
|
||||
}
|
||||
|
||||
// get event from relay
|
||||
const event = await ark.getEventById({ id });
|
||||
|
||||
if (!event)
|
||||
throw new Error(`Cannot get event with ${id}, will be retry after 10 seconds`);
|
||||
|
||||
return event;
|
||||
},
|
||||
refetchOnWindowFocus: false,
|
||||
@ -27,5 +17,5 @@ export function useEvent(id: undefined | string, embed?: undefined | string) {
|
||||
retry: 2,
|
||||
});
|
||||
|
||||
return { status, isFetching, isError, data };
|
||||
return { status, isLoading, isError, data };
|
||||
}
|
27
src/libs/ark/hooks/useProfile.ts
Normal file
27
src/libs/ark/hooks/useProfile.ts
Normal file
@ -0,0 +1,27 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useArk } from '@libs/ark';
|
||||
|
||||
export function useProfile(pubkey: string) {
|
||||
const ark = useArk();
|
||||
const {
|
||||
isLoading,
|
||||
isError,
|
||||
data: user,
|
||||
} = useQuery({
|
||||
queryKey: ['user', pubkey],
|
||||
queryFn: async () => {
|
||||
const profile = await ark.getUserProfile({ pubkey });
|
||||
if (!profile)
|
||||
throw new Error(
|
||||
`Cannot get metadata for ${pubkey}, will be retry after 10 seconds`
|
||||
);
|
||||
return profile;
|
||||
},
|
||||
refetchOnMount: false,
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnReconnect: false,
|
||||
retry: 2,
|
||||
});
|
||||
|
||||
return { isLoading, isError, user };
|
||||
}
|
@ -3,7 +3,6 @@ import { nip19 } from 'nostr-tools';
|
||||
import { ReactNode } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import reactStringReplace from 'react-string-replace';
|
||||
import { useArk } from '@libs/ark';
|
||||
import {
|
||||
Hashtag,
|
||||
ImagePreview,
|
||||
@ -11,7 +10,8 @@ import {
|
||||
MentionNote,
|
||||
MentionUser,
|
||||
VideoPreview,
|
||||
} from '@shared/notes';
|
||||
useStorage,
|
||||
} from '@libs/ark';
|
||||
|
||||
const NOSTR_MENTIONS = [
|
||||
'@npub1',
|
||||
@ -54,7 +54,7 @@ const VIDEOS = [
|
||||
];
|
||||
|
||||
export function useRichContent(content: string, textmode: boolean = false) {
|
||||
const ark = useArk();
|
||||
const storage = useStorage();
|
||||
|
||||
let parsedContent: string | ReactNode[] = content.replace(/\n+/g, '\n');
|
||||
let linkPreview: string;
|
||||
@ -66,7 +66,7 @@ export function useRichContent(content: string, textmode: boolean = false) {
|
||||
const words = text.split(/( |\n)/);
|
||||
|
||||
if (!textmode) {
|
||||
if (ark.settings.media) {
|
||||
if (storage.settings.media) {
|
||||
images = words.filter((word) => IMAGES.some((el) => word.endsWith(el)));
|
||||
videos = words.filter((word) => VIDEOS.some((el) => word.endsWith(el)));
|
||||
}
|
||||
@ -98,7 +98,7 @@ export function useRichContent(content: string, textmode: boolean = false) {
|
||||
if (hashtags.length) {
|
||||
hashtags.forEach((hashtag) => {
|
||||
parsedContent = reactStringReplace(parsedContent, hashtag, (match, i) => {
|
||||
if (ark.settings.hashtag) return <Hashtag key={match + i} tag={hashtag} />;
|
||||
if (storage.settings.hashtag) return <Hashtag key={match + i} tag={hashtag} />;
|
||||
return null;
|
||||
});
|
||||
});
|
||||
@ -111,13 +111,13 @@ export function useRichContent(content: string, textmode: boolean = false) {
|
||||
|
||||
if (decoded.type === 'note') {
|
||||
parsedContent = reactStringReplace(parsedContent, event, (match, i) => (
|
||||
<MentionNote key={match + i} id={decoded.data} />
|
||||
<MentionNote key={match + i} eventId={decoded.data} />
|
||||
));
|
||||
}
|
||||
|
||||
if (decoded.type === 'nevent') {
|
||||
parsedContent = reactStringReplace(parsedContent, event, (match, i) => (
|
||||
<MentionNote key={match + i} id={decoded.data.id} />
|
||||
<MentionNote key={match + i} eventId={decoded.data.id} />
|
||||
));
|
||||
}
|
||||
});
|
@ -1,22 +1,28 @@
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useArk } from '@libs/ark';
|
||||
import { Widget } from '@utils/types';
|
||||
import { useStorage } from '@libs/ark';
|
||||
import { WidgetProps } from '@utils/types';
|
||||
|
||||
export function useWidget() {
|
||||
const ark = useArk();
|
||||
const storage = useStorage();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const addWidget = useMutation({
|
||||
mutationFn: async (widget: Widget) => {
|
||||
return await ark.createWidget(widget.kind, widget.title, widget.content);
|
||||
mutationFn: async (widget: WidgetProps) => {
|
||||
return await storage.createWidget(widget.kind, widget.title, widget.content);
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
queryClient.setQueryData(['widgets'], (old: Widget[]) => [...old, data]);
|
||||
queryClient.setQueryData(['widgets'], (old: WidgetProps[]) => [...old, data]);
|
||||
},
|
||||
});
|
||||
|
||||
const replaceWidget = useMutation({
|
||||
mutationFn: async ({ currentId, widget }: { currentId: string; widget: Widget }) => {
|
||||
mutationFn: async ({
|
||||
currentId,
|
||||
widget,
|
||||
}: {
|
||||
currentId: string;
|
||||
widget: WidgetProps;
|
||||
}) => {
|
||||
// Cancel any outgoing refetches
|
||||
await queryClient.cancelQueries({ queryKey: ['widgets'] });
|
||||
|
||||
@ -24,11 +30,15 @@ export function useWidget() {
|
||||
const prevWidgets = queryClient.getQueryData(['widgets']);
|
||||
|
||||
// create new widget
|
||||
await ark.removeWidget(currentId);
|
||||
const newWidget = await ark.createWidget(widget.kind, widget.title, widget.content);
|
||||
await storage.removeWidget(currentId);
|
||||
const newWidget = await storage.createWidget(
|
||||
widget.kind,
|
||||
widget.title,
|
||||
widget.content
|
||||
);
|
||||
|
||||
// Optimistically update to the new value
|
||||
queryClient.setQueryData(['widgets'], (prev: Widget[]) => [
|
||||
queryClient.setQueryData(['widgets'], (prev: WidgetProps[]) => [
|
||||
...prev.filter((t) => t.id !== currentId),
|
||||
newWidget,
|
||||
]);
|
||||
@ -50,12 +60,12 @@ export function useWidget() {
|
||||
const prevWidgets = queryClient.getQueryData(['widgets']);
|
||||
|
||||
// Optimistically update to the new value
|
||||
queryClient.setQueryData(['widgets'], (prev: Widget[]) =>
|
||||
queryClient.setQueryData(['widgets'], (prev: WidgetProps[]) =>
|
||||
prev.filter((t) => t.id !== id)
|
||||
);
|
||||
|
||||
// Update in database
|
||||
await ark.removeWidget(id);
|
||||
await storage.removeWidget(id);
|
||||
|
||||
// Return a context object with the snapshotted value
|
||||
return { prevWidgets };
|
@ -2,3 +2,7 @@ export * from './ark';
|
||||
export * from './provider';
|
||||
export * from './components/widget';
|
||||
export * from './components/note';
|
||||
export * from './hooks/useWidget';
|
||||
export * from './hooks/useRichContent';
|
||||
export * from './hooks/useEvent';
|
||||
export * from './hooks/useProfile';
|
||||
|
@ -1,57 +1,174 @@
|
||||
import { ask } from '@tauri-apps/plugin-dialog';
|
||||
import NDK, { NDKNip46Signer, NDKPrivateKeySigner } from '@nostr-dev-kit/ndk';
|
||||
import { ndkAdapter } from '@nostr-fetch/adapter-ndk';
|
||||
import { platform } from '@tauri-apps/plugin-os';
|
||||
import { relaunch } from '@tauri-apps/plugin-process';
|
||||
import Database from '@tauri-apps/plugin-sql';
|
||||
import { check } from '@tauri-apps/plugin-updater';
|
||||
import Markdown from 'markdown-to-jsx';
|
||||
import { PropsWithChildren, createContext, useContext, useEffect, useState } from 'react';
|
||||
import { NostrFetcher, normalizeRelayUrl, normalizeRelayUrlSet } from 'nostr-fetch';
|
||||
import { PropsWithChildren, useEffect, useState } from 'react';
|
||||
import { createContext, useContextSelector } from 'use-context-selector';
|
||||
import { Ark } from '@libs/ark';
|
||||
import { NDKCacheAdapterTauri } from '@libs/cache';
|
||||
import { LumeStorage } from '@libs/storage';
|
||||
import { LoaderIcon } from '@shared/icons';
|
||||
import { QUOTES } from '@utils/constants';
|
||||
import { delay } from '@utils/delay';
|
||||
|
||||
const ArkContext = createContext<Ark>(undefined);
|
||||
type Context = {
|
||||
storage: LumeStorage;
|
||||
ark: Ark;
|
||||
};
|
||||
|
||||
const ArkProvider = ({ children }: PropsWithChildren<object>) => {
|
||||
const [ark, setArk] = useState<Ark>(undefined);
|
||||
const LumeContext = createContext<Context>({
|
||||
storage: undefined,
|
||||
ark: undefined,
|
||||
});
|
||||
|
||||
const LumeProvider = ({ children }: PropsWithChildren<object>) => {
|
||||
const [context, setContext] = useState<Context>(undefined);
|
||||
const [isNewVersion, setIsNewVersion] = useState(false);
|
||||
|
||||
async function initArk() {
|
||||
async function initNostrSigner({
|
||||
storage,
|
||||
nsecbunker,
|
||||
}: {
|
||||
storage: LumeStorage;
|
||||
nsecbunker?: boolean;
|
||||
}) {
|
||||
try {
|
||||
const sqlite = await Database.load('sqlite:lume_v2.db');
|
||||
const platformName = await platform();
|
||||
if (!storage.account) return null;
|
||||
|
||||
const _ark = new Ark({ storage: sqlite, platform: platformName });
|
||||
await _ark.init();
|
||||
// NIP-46 Signer
|
||||
if (nsecbunker) {
|
||||
const localSignerPrivkey = await storage.loadPrivkey(
|
||||
`${storage.account.id}-nsecbunker`
|
||||
);
|
||||
|
||||
// check update
|
||||
if (_ark.settings.autoupdate) {
|
||||
const update = await check();
|
||||
// install new version
|
||||
if (update) {
|
||||
setIsNewVersion(true);
|
||||
if (!localSignerPrivkey) return null;
|
||||
|
||||
await update.downloadAndInstall();
|
||||
await relaunch();
|
||||
}
|
||||
const localSigner = new NDKPrivateKeySigner(localSignerPrivkey);
|
||||
const bunker = new NDK({
|
||||
explicitRelayUrls: normalizeRelayUrlSet([
|
||||
'wss://relay.nsecbunker.com/',
|
||||
'wss://nostr.vulpem.com/',
|
||||
]),
|
||||
});
|
||||
await bunker.connect(3000);
|
||||
|
||||
const remoteSigner = new NDKNip46Signer(
|
||||
bunker,
|
||||
storage.account.pubkey,
|
||||
localSigner
|
||||
);
|
||||
await remoteSigner.blockUntilReady();
|
||||
|
||||
return remoteSigner;
|
||||
}
|
||||
|
||||
setArk(_ark);
|
||||
// Privkey Signer
|
||||
const userPrivkey = await storage.loadPrivkey(storage.account.pubkey);
|
||||
|
||||
if (!userPrivkey) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return new NDKPrivateKeySigner(userPrivkey);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
const yes = await ask(`${e}. Click "Yes" to relaunch app`, {
|
||||
title: 'Lume',
|
||||
type: 'error',
|
||||
okLabel: 'Yes',
|
||||
});
|
||||
if (yes) relaunch();
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function init() {
|
||||
const platformName = await platform();
|
||||
const sqliteAdapter = await Database.load('sqlite:lume_v2.db');
|
||||
|
||||
const storage = new LumeStorage(sqliteAdapter, platformName);
|
||||
storage.init();
|
||||
|
||||
// check for new update
|
||||
if (storage.settings.autoupdate) {
|
||||
const update = await check();
|
||||
// install new version
|
||||
if (update) {
|
||||
setIsNewVersion(true);
|
||||
|
||||
await update.downloadAndInstall();
|
||||
await relaunch();
|
||||
}
|
||||
}
|
||||
|
||||
const explicitRelayUrls = normalizeRelayUrlSet([
|
||||
'wss://relay.damus.io',
|
||||
'wss://relay.nostr.band/all',
|
||||
'wss://nostr.mutinywallet.com',
|
||||
]);
|
||||
|
||||
if (storage.settings.depot) {
|
||||
await storage.launchDepot();
|
||||
await delay(2000);
|
||||
|
||||
explicitRelayUrls.push(normalizeRelayUrl('ws://localhost:6090'));
|
||||
}
|
||||
|
||||
// #TODO: user should config outbox relays
|
||||
const outboxRelayUrls = normalizeRelayUrlSet(['wss://purplepag.es']);
|
||||
|
||||
// #TODO: user should config blacklist relays
|
||||
// No need to connect depot tunnel url
|
||||
const blacklistRelayUrls = storage.settings.tunnelUrl.length
|
||||
? [storage.settings.tunnelUrl, storage.settings.tunnelUrl + '/']
|
||||
: [];
|
||||
|
||||
const cacheAdapter = new NDKCacheAdapterTauri(storage);
|
||||
const ndk = new NDK({
|
||||
cacheAdapter,
|
||||
explicitRelayUrls,
|
||||
outboxRelayUrls,
|
||||
blacklistRelayUrls,
|
||||
enableOutboxModel: storage.settings.lowPowerMode ? false : storage.settings.outbox,
|
||||
autoConnectUserRelays: storage.settings.lowPowerMode ? false : true,
|
||||
autoFetchUserMutelist: storage.settings.lowPowerMode ? false : true,
|
||||
// clientName: 'Lume',
|
||||
// clientNip89: '',
|
||||
});
|
||||
|
||||
// add signer
|
||||
const signer = await initNostrSigner({
|
||||
storage,
|
||||
nsecbunker: storage.settings.bunker,
|
||||
});
|
||||
|
||||
if (signer) ndk.signer = signer;
|
||||
|
||||
// connect
|
||||
await ndk.connect(3000);
|
||||
|
||||
// update account's metadata
|
||||
if (storage.account) {
|
||||
const user = ndk.getUser({ pubkey: storage.account.pubkey });
|
||||
ndk.activeUser = user;
|
||||
|
||||
const contacts = await user.follows();
|
||||
storage.account.contacts = [...contacts].map((user) => user.pubkey);
|
||||
}
|
||||
|
||||
// init nostr fetcher
|
||||
const fetcher = NostrFetcher.withCustomPool(ndkAdapter(ndk));
|
||||
|
||||
// ark utils
|
||||
const ark = new Ark({ storage, ndk, fetcher });
|
||||
|
||||
// update context
|
||||
setContext({ ark, storage });
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!ark && !isNewVersion) initArk();
|
||||
if (!context && !isNewVersion) init();
|
||||
}, []);
|
||||
|
||||
if (!ark) {
|
||||
if (!context) {
|
||||
return (
|
||||
<div
|
||||
data-tauri-drag-region
|
||||
@ -85,15 +202,27 @@ const ArkProvider = ({ children }: PropsWithChildren<object>) => {
|
||||
);
|
||||
}
|
||||
|
||||
return <ArkContext.Provider value={ark}>{children}</ArkContext.Provider>;
|
||||
return (
|
||||
<LumeContext.Provider value={{ ark: context.ark, storage: context.storage }}>
|
||||
{children}
|
||||
</LumeContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
const useArk = () => {
|
||||
const context = useContext(ArkContext);
|
||||
const context = useContextSelector(LumeContext, (state) => state.ark);
|
||||
if (context === undefined) {
|
||||
throw new Error('Please import Ark Provider to use useArk() hook');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
export { ArkProvider, useArk };
|
||||
const useStorage = () => {
|
||||
const context = useContextSelector(LumeContext, (state) => state.storage);
|
||||
if (context === undefined) {
|
||||
throw new Error('Please import Ark Provider to use useStorage() hook');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
export { LumeProvider, useArk, useStorage };
|
||||
|
142
src/libs/cache/index.ts
vendored
142
src/libs/cache/index.ts
vendored
@ -10,20 +10,19 @@ import {
|
||||
NDKUserProfile,
|
||||
profileFromEvent,
|
||||
} from '@nostr-dev-kit/ndk';
|
||||
import Database from '@tauri-apps/plugin-sql';
|
||||
import { LRUCache } from 'lru-cache';
|
||||
import { NostrEvent } from 'nostr-fetch';
|
||||
import { matchFilter } from 'nostr-tools';
|
||||
import { NDKCacheEvent, NDKCacheEventTag, NDKCacheUser } from '@utils/types';
|
||||
import { LumeStorage } from '@libs/storage';
|
||||
|
||||
export class NDKCacheAdapterTauri implements NDKCacheAdapter {
|
||||
#db: Database;
|
||||
#storage: LumeStorage;
|
||||
private dirtyProfiles: Set<Hexpubkey> = new Set();
|
||||
public profiles?: LRUCache<Hexpubkey, NDKUserProfile>;
|
||||
readonly locking: boolean;
|
||||
|
||||
constructor(db: Database) {
|
||||
this.#db = db;
|
||||
constructor(storage: LumeStorage) {
|
||||
this.#storage = storage;
|
||||
this.locking = true;
|
||||
|
||||
this.profiles = new LRUCache({
|
||||
@ -35,115 +34,6 @@ export class NDKCacheAdapterTauri implements NDKCacheAdapter {
|
||||
}, 1000 * 10);
|
||||
}
|
||||
|
||||
async #getCacheUser(pubkey: string) {
|
||||
const results: Array<NDKCacheUser> = await this.#db.select(
|
||||
'SELECT * FROM ndk_users WHERE pubkey = $1 ORDER BY pubkey DESC LIMIT 1;',
|
||||
[pubkey]
|
||||
);
|
||||
|
||||
if (!results.length) return null;
|
||||
|
||||
if (typeof results[0].profile === 'string')
|
||||
results[0].profile = JSON.parse(results[0].profile);
|
||||
|
||||
return results[0];
|
||||
}
|
||||
|
||||
async #getCacheEvent(id: string) {
|
||||
const results: Array<NDKCacheEvent> = await this.#db.select(
|
||||
'SELECT * FROM ndk_events WHERE id = $1 ORDER BY id DESC LIMIT 1;',
|
||||
[id]
|
||||
);
|
||||
|
||||
if (!results.length) return null;
|
||||
return results[0];
|
||||
}
|
||||
|
||||
async #getCacheEvents(ids: string[]) {
|
||||
const idsArr = `'${ids.join("','")}'`;
|
||||
|
||||
const results: Array<NDKCacheEvent> = await this.#db.select(
|
||||
`SELECT * FROM ndk_events WHERE id IN (${idsArr}) ORDER BY id;`
|
||||
);
|
||||
|
||||
if (!results.length) return [];
|
||||
return results;
|
||||
}
|
||||
|
||||
async #getCacheEventsByPubkey(pubkey: string) {
|
||||
const results: Array<NDKCacheEvent> = await this.#db.select(
|
||||
'SELECT * FROM ndk_events WHERE pubkey = $1 ORDER BY id;',
|
||||
[pubkey]
|
||||
);
|
||||
|
||||
if (!results.length) return [];
|
||||
return results;
|
||||
}
|
||||
|
||||
async #getCacheEventsByKind(kind: number) {
|
||||
const results: Array<NDKCacheEvent> = await this.#db.select(
|
||||
'SELECT * FROM ndk_events WHERE kind = $1 ORDER BY id;',
|
||||
[kind]
|
||||
);
|
||||
|
||||
if (!results.length) return [];
|
||||
return results;
|
||||
}
|
||||
|
||||
async #getCacheEventsByKindAndAuthor(kind: number, pubkey: string) {
|
||||
const results: Array<NDKCacheEvent> = await this.#db.select(
|
||||
'SELECT * FROM ndk_events WHERE kind = $1 AND pubkey = $2 ORDER BY id;',
|
||||
[kind, pubkey]
|
||||
);
|
||||
|
||||
if (!results.length) return [];
|
||||
return results;
|
||||
}
|
||||
|
||||
async #getCacheEventTagsByTagValue(tagValue: string) {
|
||||
const results: Array<NDKCacheEventTag> = await this.#db.select(
|
||||
'SELECT * FROM ndk_eventtags WHERE tagValue = $1 ORDER BY id;',
|
||||
[tagValue]
|
||||
);
|
||||
|
||||
if (!results.length) return [];
|
||||
return results;
|
||||
}
|
||||
|
||||
async #setCacheEvent({
|
||||
id,
|
||||
pubkey,
|
||||
content,
|
||||
kind,
|
||||
createdAt,
|
||||
relay,
|
||||
event,
|
||||
}: NDKCacheEvent) {
|
||||
return await this.#db.execute(
|
||||
'INSERT OR IGNORE INTO ndk_events (id, pubkey, content, kind, createdAt, relay, event) VALUES ($1, $2, $3, $4, $5, $6, $7);',
|
||||
[id, pubkey, content, kind, createdAt, relay, event]
|
||||
);
|
||||
}
|
||||
|
||||
async #setCacheEventTag({ id, eventId, tag, value, tagValue }: NDKCacheEventTag) {
|
||||
return await this.#db.execute(
|
||||
'INSERT OR IGNORE INTO ndk_eventtags (id, eventId, tag, value, tagValue) VALUES ($1, $2, $3, $4, $5);',
|
||||
[id, eventId, tag, value, tagValue]
|
||||
);
|
||||
}
|
||||
|
||||
async #setCacheProfiles(profiles: Array<NDKCacheUser>) {
|
||||
return await Promise.all(
|
||||
profiles.map(
|
||||
async (profile) =>
|
||||
await this.#db.execute(
|
||||
'INSERT OR IGNORE INTO ndk_users (pubkey, profile, createdAt) VALUES ($1, $2, $3);',
|
||||
[profile.pubkey, profile.profile, profile.createdAt]
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
public async query(subscription: NDKSubscription): Promise<void> {
|
||||
Promise.allSettled(
|
||||
subscription.filters.map((filter) => this.processFilter(filter, subscription))
|
||||
@ -156,7 +46,7 @@ export class NDKCacheAdapterTauri implements NDKCacheAdapter {
|
||||
let profile = this.profiles.get(pubkey);
|
||||
|
||||
if (!profile) {
|
||||
const user = await this.#getCacheUser(pubkey);
|
||||
const user = await this.#storage.getCacheUser(pubkey);
|
||||
if (user) {
|
||||
profile = user.profile as NDKUserProfile;
|
||||
this.profiles.set(pubkey, profile);
|
||||
@ -211,7 +101,7 @@ export class NDKCacheAdapterTauri implements NDKCacheAdapter {
|
||||
|
||||
if (event.isParamReplaceable()) {
|
||||
const replaceableId = `${event.kind}:${event.pubkey}:${event.tagId()}`;
|
||||
const existingEvent = await this.#getCacheEvent(replaceableId);
|
||||
const existingEvent = await this.#storage.getCacheEvent(replaceableId);
|
||||
if (
|
||||
existingEvent &&
|
||||
event.created_at &&
|
||||
@ -222,7 +112,7 @@ export class NDKCacheAdapterTauri implements NDKCacheAdapter {
|
||||
}
|
||||
|
||||
if (addEvent) {
|
||||
this.#setCacheEvent({
|
||||
this.#storage.setCacheEvent({
|
||||
id: event.tagId(),
|
||||
pubkey: event.pubkey,
|
||||
content: event.content,
|
||||
@ -238,7 +128,7 @@ export class NDKCacheAdapterTauri implements NDKCacheAdapter {
|
||||
event.tags.forEach((tag) => {
|
||||
if (tag[0].length !== 1) return;
|
||||
|
||||
this.#setCacheEventTag({
|
||||
this.#storage.setCacheEventTag({
|
||||
id: `${event.id}:${tag[0]}:${tag[1]}`,
|
||||
eventId: event.id,
|
||||
tag: tag[0],
|
||||
@ -267,7 +157,7 @@ export class NDKCacheAdapterTauri implements NDKCacheAdapter {
|
||||
|
||||
if (hasAllKeys && filter.authors) {
|
||||
for (const pubkey of filter.authors) {
|
||||
const events = await this.#getCacheEventsByPubkey(pubkey);
|
||||
const events = await this.#storage.getCacheEventsByPubkey(pubkey);
|
||||
for (const event of events) {
|
||||
let rawEvent: NostrEvent;
|
||||
try {
|
||||
@ -303,7 +193,7 @@ export class NDKCacheAdapterTauri implements NDKCacheAdapter {
|
||||
|
||||
if (hasAllKeys && filter.kinds) {
|
||||
for (const kind of filter.kinds) {
|
||||
const events = await this.#getCacheEventsByKind(kind);
|
||||
const events = await this.#storage.getCacheEventsByKind(kind);
|
||||
for (const event of events) {
|
||||
let rawEvent: NostrEvent;
|
||||
try {
|
||||
@ -337,7 +227,7 @@ export class NDKCacheAdapterTauri implements NDKCacheAdapter {
|
||||
|
||||
if (hasAllKeys && filter.ids) {
|
||||
for (const id of filter.ids) {
|
||||
const event = await this.#getCacheEvent(id);
|
||||
const event = await this.#storage.getCacheEvent(id);
|
||||
if (!event) continue;
|
||||
|
||||
let rawEvent: NostrEvent;
|
||||
@ -380,7 +270,7 @@ export class NDKCacheAdapterTauri implements NDKCacheAdapter {
|
||||
for (const author of filter.authors) {
|
||||
for (const dTag of filter['#d']) {
|
||||
const replaceableId = `${kind}:${author}:${dTag}`;
|
||||
const event = await this.#getCacheEvent(replaceableId);
|
||||
const event = await this.#storage.getCacheEvent(replaceableId);
|
||||
if (!event) continue;
|
||||
|
||||
let rawEvent: NostrEvent;
|
||||
@ -420,7 +310,7 @@ export class NDKCacheAdapterTauri implements NDKCacheAdapter {
|
||||
if (filter.kinds && filter.authors) {
|
||||
for (const kind of filter.kinds) {
|
||||
for (const author of filter.authors) {
|
||||
const events = await this.#getCacheEventsByKindAndAuthor(kind, author);
|
||||
const events = await this.#storage.getCacheEventsByKindAndAuthor(kind, author);
|
||||
|
||||
for (const event of events) {
|
||||
let rawEvent: NostrEvent;
|
||||
@ -485,12 +375,12 @@ export class NDKCacheAdapterTauri implements NDKCacheAdapter {
|
||||
}
|
||||
|
||||
for (const value of values) {
|
||||
const eventTags = await this.#getCacheEventTagsByTagValue(tag + value);
|
||||
const eventTags = await this.#storage.getCacheEventTagsByTagValue(tag + value);
|
||||
if (!eventTags.length) continue;
|
||||
|
||||
const eventIds = eventTags.map((t) => t.eventId);
|
||||
|
||||
const events = await this.#getCacheEvents(eventIds);
|
||||
const events = await this.#storage.getCacheEvents(eventIds);
|
||||
for (const event of events) {
|
||||
let rawEvent;
|
||||
try {
|
||||
@ -532,7 +422,7 @@ export class NDKCacheAdapterTauri implements NDKCacheAdapter {
|
||||
}
|
||||
|
||||
if (profiles.length) {
|
||||
await this.#setCacheProfiles(profiles);
|
||||
await this.#storage.setCacheProfiles(profiles);
|
||||
}
|
||||
|
||||
this.dirtyProfiles.clear();
|
||||
|
396
src/libs/storage/index.ts
Normal file
396
src/libs/storage/index.ts
Normal file
@ -0,0 +1,396 @@
|
||||
import { appConfigDir, resolveResource } from '@tauri-apps/api/path';
|
||||
import { invoke } from '@tauri-apps/api/primitives';
|
||||
import { Platform } from '@tauri-apps/plugin-os';
|
||||
import { Child, Command } from '@tauri-apps/plugin-shell';
|
||||
import Database from '@tauri-apps/plugin-sql';
|
||||
import {
|
||||
Account,
|
||||
NDKCacheEvent,
|
||||
NDKCacheEventTag,
|
||||
NDKCacheUser,
|
||||
NDKCacheUserProfile,
|
||||
WidgetProps,
|
||||
} from '@utils/types';
|
||||
|
||||
export class LumeStorage {
|
||||
#db: Database;
|
||||
#depot: Child;
|
||||
readonly platform: Platform;
|
||||
public account: Account;
|
||||
public settings: {
|
||||
autoupdate: boolean;
|
||||
bunker: boolean;
|
||||
outbox: boolean;
|
||||
media: boolean;
|
||||
hashtag: boolean;
|
||||
depot: boolean;
|
||||
tunnelUrl: string;
|
||||
lowPowerMode: boolean;
|
||||
};
|
||||
|
||||
constructor(db: Database, platform: Platform) {
|
||||
this.#db = db;
|
||||
this.#depot = undefined;
|
||||
this.platform = platform;
|
||||
this.settings = {
|
||||
autoupdate: false,
|
||||
bunker: false,
|
||||
outbox: false,
|
||||
media: true,
|
||||
hashtag: true,
|
||||
depot: false,
|
||||
tunnelUrl: '',
|
||||
lowPowerMode: false,
|
||||
};
|
||||
}
|
||||
|
||||
public async init() {
|
||||
const settings = await this.getAllSettings();
|
||||
|
||||
for (const item of settings) {
|
||||
if (item.key === 'nsecbunker') this.settings.bunker = !!parseInt(item.value);
|
||||
if (item.key === 'outbox') this.settings.outbox = !!parseInt(item.value);
|
||||
if (item.key === 'media') this.settings.media = !!parseInt(item.value);
|
||||
if (item.key === 'hashtag') this.settings.hashtag = !!parseInt(item.value);
|
||||
if (item.key === 'autoupdate') this.settings.autoupdate = !!parseInt(item.value);
|
||||
if (item.key === 'depot') this.settings.depot = !!parseInt(item.value);
|
||||
if (item.key === 'tunnel_url') this.settings.tunnelUrl = item.value;
|
||||
}
|
||||
|
||||
const account = await this.getActiveAccount();
|
||||
if (account) this.account = account;
|
||||
}
|
||||
|
||||
async #keyring_save(key: string, value: string) {
|
||||
return await invoke('secure_save', { key, value });
|
||||
}
|
||||
|
||||
async #keyring_load(key: string) {
|
||||
try {
|
||||
const value: string = await invoke('secure_load', { key });
|
||||
if (!value) return null;
|
||||
return value;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async #keyring_remove(key: string) {
|
||||
return await invoke('secure_remove', { key });
|
||||
}
|
||||
|
||||
public async launchDepot() {
|
||||
const configPath = await resolveResource('resources/config.toml');
|
||||
const dataPath = await appConfigDir();
|
||||
|
||||
const command = Command.sidecar('bin/depot', ['-c', configPath, '-d', dataPath]);
|
||||
this.#depot = await command.spawn();
|
||||
}
|
||||
|
||||
public checkDepot() {
|
||||
if (this.#depot) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
public async stopDepot() {
|
||||
if (this.#depot) return this.#depot.kill();
|
||||
}
|
||||
|
||||
public async getCacheUser(pubkey: string) {
|
||||
const results: Array<NDKCacheUser> = await this.#db.select(
|
||||
'SELECT * FROM ndk_users WHERE pubkey = $1 ORDER BY pubkey DESC LIMIT 1;',
|
||||
[pubkey]
|
||||
);
|
||||
|
||||
if (!results.length) return null;
|
||||
|
||||
if (typeof results[0].profile === 'string')
|
||||
results[0].profile = JSON.parse(results[0].profile);
|
||||
|
||||
return results[0];
|
||||
}
|
||||
|
||||
public async getCacheEvent(id: string) {
|
||||
const results: Array<NDKCacheEvent> = await this.#db.select(
|
||||
'SELECT * FROM ndk_events WHERE id = $1 ORDER BY id DESC LIMIT 1;',
|
||||
[id]
|
||||
);
|
||||
|
||||
if (!results.length) return null;
|
||||
return results[0];
|
||||
}
|
||||
|
||||
public async getCacheEvents(ids: string[]) {
|
||||
const idsArr = `'${ids.join("','")}'`;
|
||||
|
||||
const results: Array<NDKCacheEvent> = await this.#db.select(
|
||||
`SELECT * FROM ndk_events WHERE id IN (${idsArr}) ORDER BY id;`
|
||||
);
|
||||
|
||||
if (!results.length) return [];
|
||||
return results;
|
||||
}
|
||||
|
||||
public async getCacheEventsByPubkey(pubkey: string) {
|
||||
const results: Array<NDKCacheEvent> = await this.#db.select(
|
||||
'SELECT * FROM ndk_events WHERE pubkey = $1 ORDER BY id;',
|
||||
[pubkey]
|
||||
);
|
||||
|
||||
if (!results.length) return [];
|
||||
return results;
|
||||
}
|
||||
|
||||
public async getCacheEventsByKind(kind: number) {
|
||||
const results: Array<NDKCacheEvent> = await this.#db.select(
|
||||
'SELECT * FROM ndk_events WHERE kind = $1 ORDER BY id;',
|
||||
[kind]
|
||||
);
|
||||
|
||||
if (!results.length) return [];
|
||||
return results;
|
||||
}
|
||||
|
||||
public async getCacheEventsByKindAndAuthor(kind: number, pubkey: string) {
|
||||
const results: Array<NDKCacheEvent> = await this.#db.select(
|
||||
'SELECT * FROM ndk_events WHERE kind = $1 AND pubkey = $2 ORDER BY id;',
|
||||
[kind, pubkey]
|
||||
);
|
||||
|
||||
if (!results.length) return [];
|
||||
return results;
|
||||
}
|
||||
|
||||
public async getCacheEventTagsByTagValue(tagValue: string) {
|
||||
const results: Array<NDKCacheEventTag> = await this.#db.select(
|
||||
'SELECT * FROM ndk_eventtags WHERE tagValue = $1 ORDER BY id;',
|
||||
[tagValue]
|
||||
);
|
||||
|
||||
if (!results.length) return [];
|
||||
return results;
|
||||
}
|
||||
|
||||
public async setCacheEvent({
|
||||
id,
|
||||
pubkey,
|
||||
content,
|
||||
kind,
|
||||
createdAt,
|
||||
relay,
|
||||
event,
|
||||
}: NDKCacheEvent) {
|
||||
return await this.#db.execute(
|
||||
'INSERT OR IGNORE INTO ndk_events (id, pubkey, content, kind, createdAt, relay, event) VALUES ($1, $2, $3, $4, $5, $6, $7);',
|
||||
[id, pubkey, content, kind, createdAt, relay, event]
|
||||
);
|
||||
}
|
||||
|
||||
public async setCacheEventTag({ id, eventId, tag, value, tagValue }: NDKCacheEventTag) {
|
||||
return await this.#db.execute(
|
||||
'INSERT OR IGNORE INTO ndk_eventtags (id, eventId, tag, value, tagValue) VALUES ($1, $2, $3, $4, $5);',
|
||||
[id, eventId, tag, value, tagValue]
|
||||
);
|
||||
}
|
||||
|
||||
public async setCacheProfiles(profiles: Array<NDKCacheUser>) {
|
||||
return await Promise.all(
|
||||
profiles.map(
|
||||
async (profile) =>
|
||||
await this.#db.execute(
|
||||
'INSERT OR IGNORE INTO ndk_users (pubkey, profile, createdAt) VALUES ($1, $2, $3);',
|
||||
[profile.pubkey, profile.profile, profile.createdAt]
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
public async getAllCacheUsers() {
|
||||
const results: Array<NDKCacheUser> = await this.#db.select(
|
||||
'SELECT * FROM ndk_users ORDER BY createdAt DESC;'
|
||||
);
|
||||
|
||||
if (!results.length) return [];
|
||||
|
||||
const users: NDKCacheUserProfile[] = results.map((item) => ({
|
||||
pubkey: item.pubkey,
|
||||
...JSON.parse(item.profile as string),
|
||||
}));
|
||||
return users;
|
||||
}
|
||||
|
||||
public async checkAccount() {
|
||||
const result: Array<{ total: string }> = await this.#db.select(
|
||||
'SELECT COUNT(*) AS "total" FROM accounts WHERE is_active = "1" ORDER BY id DESC LIMIT 1;'
|
||||
);
|
||||
return parseInt(result[0].total);
|
||||
}
|
||||
|
||||
public async getActiveAccount() {
|
||||
const results: Array<Account> = await this.#db.select(
|
||||
'SELECT * FROM accounts WHERE is_active = "1" ORDER BY id DESC LIMIT 1;'
|
||||
);
|
||||
|
||||
if (results.length) {
|
||||
this.account = results[0];
|
||||
return results[0];
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public async createAccount({
|
||||
id,
|
||||
pubkey,
|
||||
privkey,
|
||||
}: {
|
||||
id: string;
|
||||
pubkey: string;
|
||||
privkey?: string;
|
||||
}) {
|
||||
const existAccounts: Array<Account> = await this.#db.select(
|
||||
'SELECT * FROM accounts WHERE pubkey = $1 ORDER BY id DESC LIMIT 1;',
|
||||
[pubkey]
|
||||
);
|
||||
|
||||
if (existAccounts.length) {
|
||||
await this.#db.execute("UPDATE accounts SET is_active = '1' WHERE pubkey = $1;", [
|
||||
pubkey,
|
||||
]);
|
||||
} else {
|
||||
await this.#db.execute(
|
||||
'INSERT OR IGNORE INTO accounts (id, pubkey, is_active) VALUES ($1, $2, $3);',
|
||||
[id, pubkey, 1]
|
||||
);
|
||||
|
||||
if (privkey) await this.#keyring_save(pubkey, privkey);
|
||||
}
|
||||
|
||||
const account = await this.getActiveAccount();
|
||||
this.account = account;
|
||||
this.account.contacts = [];
|
||||
|
||||
return account;
|
||||
}
|
||||
|
||||
/**
|
||||
* Save private key to OS secure storage
|
||||
* @deprecated this method will be remove in the next update
|
||||
*/
|
||||
public async createPrivkey(name: string, privkey: string) {
|
||||
return await this.#keyring_save(name, privkey);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load private key from OS secure storage
|
||||
* @deprecated this method will be remove in the next update
|
||||
*/
|
||||
public async loadPrivkey(name: string) {
|
||||
return await this.#keyring_load(name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove private key from OS secure storage
|
||||
* @deprecated this method will be remove in the next update
|
||||
*/
|
||||
public async removePrivkey(name: string) {
|
||||
return await this.#keyring_remove(name);
|
||||
}
|
||||
|
||||
public async updateAccount(column: string, value: string) {
|
||||
const insert = await this.#db.execute(
|
||||
`UPDATE accounts SET ${column} = $1 WHERE id = $2;`,
|
||||
[value, this.account.id]
|
||||
);
|
||||
|
||||
if (insert) {
|
||||
const account = await this.getActiveAccount();
|
||||
return account;
|
||||
}
|
||||
}
|
||||
|
||||
public async getWidgets() {
|
||||
const widgets: Array<WidgetProps> = await this.#db.select(
|
||||
'SELECT * FROM widgets WHERE account_id = $1 ORDER BY created_at DESC;',
|
||||
[this.account.id]
|
||||
);
|
||||
return widgets;
|
||||
}
|
||||
|
||||
public async createWidget(kind: number, title: string, content: string | string[]) {
|
||||
const insert = await this.#db.execute(
|
||||
'INSERT INTO widgets (account_id, kind, title, content) VALUES ($1, $2, $3, $4);',
|
||||
[this.account.id, kind, title, content]
|
||||
);
|
||||
|
||||
if (insert) {
|
||||
const widgets: Array<WidgetProps> = await this.#db.select(
|
||||
'SELECT * FROM widgets ORDER BY id DESC LIMIT 1;'
|
||||
);
|
||||
if (widgets.length < 1) console.error('get created widget failed');
|
||||
return widgets[0];
|
||||
} else {
|
||||
console.error('create widget failed');
|
||||
}
|
||||
}
|
||||
|
||||
public async removeWidget(id: string) {
|
||||
const res = await this.#db.execute('DELETE FROM widgets WHERE id = $1;', [id]);
|
||||
if (res) return id;
|
||||
}
|
||||
|
||||
public async createSetting(key: string, value: string | undefined) {
|
||||
const currentSetting = await this.checkSettingValue(key);
|
||||
|
||||
if (!currentSetting) {
|
||||
return await this.#db.execute(
|
||||
'INSERT OR IGNORE INTO settings (key, value) VALUES ($1, $2);',
|
||||
[key, value]
|
||||
);
|
||||
}
|
||||
|
||||
return await this.#db.execute('UPDATE settings SET value = $1 WHERE key = $2;', [
|
||||
value,
|
||||
key,
|
||||
]);
|
||||
}
|
||||
|
||||
public async getAllSettings() {
|
||||
const results: { key: string; value: string }[] = await this.#db.select(
|
||||
'SELECT * FROM settings ORDER BY id DESC;'
|
||||
);
|
||||
if (results.length < 1) return [];
|
||||
return results;
|
||||
}
|
||||
|
||||
public async checkSettingValue(key: string) {
|
||||
const results: { key: string; value: string }[] = await this.#db.select(
|
||||
'SELECT * FROM settings WHERE key = $1 ORDER BY id DESC LIMIT 1;',
|
||||
[key]
|
||||
);
|
||||
if (!results.length) return false;
|
||||
return results[0].value;
|
||||
}
|
||||
|
||||
public async getSettingValue(key: string) {
|
||||
const results: { key: string; value: string }[] = await this.#db.select(
|
||||
'SELECT * FROM settings WHERE key = $1 ORDER BY id DESC LIMIT 1;',
|
||||
[key]
|
||||
);
|
||||
if (!results.length) return '0';
|
||||
return results[0].value;
|
||||
}
|
||||
|
||||
public async clearCache() {
|
||||
await this.#db.execute('DELETE FROM ndk_events;');
|
||||
await this.#db.execute('DELETE FROM ndk_eventtags;');
|
||||
await this.#db.execute('DELETE FROM ndk_users;');
|
||||
}
|
||||
|
||||
public async logout() {
|
||||
this.account = null;
|
||||
return await this.#db.execute("UPDATE accounts SET is_active = '0' WHERE id = $1;", [
|
||||
this.account.id,
|
||||
]);
|
||||
}
|
||||
}
|
@ -1,7 +1,7 @@
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { Toaster } from 'sonner';
|
||||
import { ArkProvider } from '@libs/ark/provider';
|
||||
import { LumeProvider } from '@libs/ark';
|
||||
import App from './app';
|
||||
import './app.css';
|
||||
|
||||
@ -19,8 +19,8 @@ const root = createRoot(container);
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Toaster position="top-center" theme="system" closeButton />
|
||||
<ArkProvider>
|
||||
<LumeProvider>
|
||||
<App />
|
||||
</ArkProvider>
|
||||
</LumeProvider>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
|
@ -2,20 +2,20 @@ import * as Avatar from '@radix-ui/react-avatar';
|
||||
import { minidenticon } from 'minidenticons';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
import { useArk } from '@libs/ark';
|
||||
import { AccountMoreActions } from '@shared/accounts/more';
|
||||
import { useStorage } from '@libs/ark';
|
||||
import { AccountMoreActions } from '@shared/account/more';
|
||||
import { useNetworkStatus } from '@utils/hooks/useNetworkStatus';
|
||||
import { useProfile } from '@utils/hooks/useProfile';
|
||||
|
||||
export function ActiveAccount() {
|
||||
const ark = useArk();
|
||||
const { user } = useProfile(ark.account.pubkey);
|
||||
|
||||
const storage = useStorage();
|
||||
const isOnline = useNetworkStatus();
|
||||
|
||||
const { user } = useProfile(storage.account.pubkey);
|
||||
|
||||
const svgURI =
|
||||
'data:image/svg+xml;utf8,' +
|
||||
encodeURIComponent(minidenticon(ark.account.pubkey, 90, 50));
|
||||
encodeURIComponent(minidenticon(storage.account.pubkey, 90, 50));
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1 rounded-xl bg-black/10 p-1 ring-1 ring-transparent hover:bg-black/20 hover:ring-blue-500 dark:bg-white/10 dark:hover:bg-white/20">
|
||||
@ -23,7 +23,7 @@ export function ActiveAccount() {
|
||||
<Avatar.Root>
|
||||
<Avatar.Image
|
||||
src={user?.picture || user?.image}
|
||||
alt={ark.account.pubkey}
|
||||
alt={storage.account.pubkey}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
style={{ contentVisibility: 'auto' }}
|
||||
@ -32,7 +32,7 @@ export function ActiveAccount() {
|
||||
<Avatar.Fallback delayMs={150}>
|
||||
<img
|
||||
src={svgURI}
|
||||
alt={ark.account.pubkey}
|
||||
alt={storage.account.pubkey}
|
||||
className="aspect-square h-auto w-full rounded-lg bg-black dark:bg-white"
|
||||
/>
|
||||
</Avatar.Fallback>
|
@ -1,6 +1,6 @@
|
||||
import * as DropdownMenu from '@radix-ui/react-dropdown-menu';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Logout } from '@shared/accounts/logout';
|
||||
import { Logout } from '@shared/account/logout';
|
||||
import { HorizontalDotsIcon } from '@shared/icons';
|
||||
|
||||
export function AccountMoreActions() {
|
@ -1,6 +1,6 @@
|
||||
import { Link, NavLink } from 'react-router-dom';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
import { ActiveAccount } from '@shared/accounts/active';
|
||||
import { ActiveAccount } from '@shared/account/active';
|
||||
import {
|
||||
DepotIcon,
|
||||
HomeIcon,
|
||||
|
@ -1,87 +0,0 @@
|
||||
import { NDKEvent } from '@nostr-dev-kit/ndk';
|
||||
import * as Tooltip from '@radix-ui/react-tooltip';
|
||||
import { createSearchParams, useNavigate } from 'react-router-dom';
|
||||
import { PinIcon, ReplyIcon } from '@shared/icons';
|
||||
import { NoteReaction } from '@shared/notes/actions/reaction';
|
||||
import { NoteRepost } from '@shared/notes/actions/repost';
|
||||
import { NoteZap } from '@shared/notes/actions/zap';
|
||||
import { WIDGET_KIND } from '@utils/constants';
|
||||
import { useWidget } from '@utils/hooks/useWidget';
|
||||
|
||||
export function NoteActions({
|
||||
event,
|
||||
rootEventId,
|
||||
canOpenEvent = true,
|
||||
}: {
|
||||
event: NDKEvent;
|
||||
rootEventId?: string;
|
||||
canOpenEvent?: boolean;
|
||||
}) {
|
||||
const { addWidget } = useWidget();
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
<Tooltip.Provider>
|
||||
<div className="flex h-14 items-center justify-between px-3">
|
||||
{canOpenEvent && (
|
||||
<div className="inline-flex items-center gap-3">
|
||||
<Tooltip.Root delayDuration={150}>
|
||||
<Tooltip.Trigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
addWidget.mutate({
|
||||
kind: WIDGET_KIND.thread,
|
||||
title: 'Thread',
|
||||
content: event.id,
|
||||
})
|
||||
}
|
||||
className="inline-flex h-7 w-max items-center justify-center gap-2 rounded-full bg-neutral-100 px-2 text-sm font-medium dark:bg-neutral-900"
|
||||
>
|
||||
<PinIcon className="size-4" />
|
||||
Pin
|
||||
</button>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Portal>
|
||||
<Tooltip.Content className="-left-10 inline-flex h-7 select-none items-center justify-center rounded-md bg-neutral-200 px-3.5 text-sm text-neutral-900 will-change-[transform,opacity] data-[state=delayed-open]:data-[side=bottom]:animate-slideUpAndFade data-[state=delayed-open]:data-[side=left]:animate-slideRightAndFade data-[state=delayed-open]:data-[side=right]:animate-slideLeftAndFade data-[state=delayed-open]:data-[side=top]:animate-slideDownAndFade dark:bg-neutral-800 dark:text-neutral-100">
|
||||
Pin note
|
||||
<Tooltip.Arrow className="fill-neutral-200 dark:fill-neutral-800" />
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Portal>
|
||||
</Tooltip.Root>
|
||||
</div>
|
||||
)}
|
||||
<div className="inline-flex items-center gap-10">
|
||||
<Tooltip.Root delayDuration={150}>
|
||||
<Tooltip.Trigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
navigate({
|
||||
pathname: '/new/',
|
||||
search: createSearchParams({
|
||||
replyTo: event.id,
|
||||
rootReplyTo: rootEventId,
|
||||
}).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" />
|
||||
</button>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Portal>
|
||||
<Tooltip.Content className="-left-10 inline-flex h-7 select-none items-center justify-center rounded-md bg-neutral-200 px-3.5 text-sm text-neutral-900 will-change-[transform,opacity] data-[state=delayed-open]:data-[side=bottom]:animate-slideUpAndFade data-[state=delayed-open]:data-[side=left]:animate-slideRightAndFade data-[state=delayed-open]:data-[side=right]:animate-slideLeftAndFade data-[state=delayed-open]:data-[side=top]:animate-slideDownAndFade dark:bg-neutral-800 dark:text-neutral-100">
|
||||
Quick reply
|
||||
<Tooltip.Arrow className="fill-neutral-200 dark:fill-neutral-800" />
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Portal>
|
||||
</Tooltip.Root>
|
||||
<NoteReaction event={event} />
|
||||
<NoteRepost event={event} />
|
||||
<NoteZap event={event} />
|
||||
</div>
|
||||
</div>
|
||||
</Tooltip.Provider>
|
||||
);
|
||||
}
|
@ -1,63 +0,0 @@
|
||||
import * as DropdownMenu from '@radix-ui/react-dropdown-menu';
|
||||
import { writeText } from '@tauri-apps/plugin-clipboard-manager';
|
||||
import { nip19 } from 'nostr-tools';
|
||||
import { EventPointer } from 'nostr-tools/lib/types/nip19';
|
||||
import { useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { HorizontalDotsIcon } from '@shared/icons';
|
||||
|
||||
export function MoreActions({ id, pubkey }: { id: string; pubkey: string }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const copyID = async () => {
|
||||
await writeText(nip19.neventEncode({ id: id, author: pubkey } as EventPointer));
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
const copyLink = async () => {
|
||||
await writeText(
|
||||
'https://njump.me/' + nip19.neventEncode({ id: id, author: pubkey } as EventPointer)
|
||||
);
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<DropdownMenu.Root open={open} onOpenChange={setOpen}>
|
||||
<DropdownMenu.Trigger asChild>
|
||||
<button type="button" className="inline-flex h-6 w-6 items-center justify-center">
|
||||
<HorizontalDotsIcon className="h-4 w-4 text-neutral-800 hover:text-blue-500 dark:text-neutral-200" />
|
||||
</button>
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Portal>
|
||||
<DropdownMenu.Content className="flex w-[200px] flex-col overflow-hidden rounded-xl border border-neutral-200 bg-neutral-100 focus:outline-none dark:border-neutral-800 dark:bg-neutral-900">
|
||||
<DropdownMenu.Item asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => copyLink()}
|
||||
className="inline-flex h-10 items-center px-4 text-sm text-neutral-900 hover:bg-neutral-200 focus:outline-none dark:text-neutral-100 dark:hover:bg-neutral-800"
|
||||
>
|
||||
Copy shareable link
|
||||
</button>
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => copyID()}
|
||||
className="inline-flex h-10 items-center px-4 text-sm text-neutral-900 hover:bg-neutral-200 focus:outline-none dark:text-neutral-100 dark:hover:bg-neutral-800"
|
||||
>
|
||||
Copy ID
|
||||
</button>
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item asChild>
|
||||
<Link
|
||||
to={`/users/${pubkey}`}
|
||||
className="inline-flex h-10 items-center px-4 text-sm text-neutral-900 hover:bg-neutral-200 focus:outline-none dark:text-neutral-100 dark:hover:bg-neutral-800"
|
||||
>
|
||||
View profile
|
||||
</Link>
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Portal>
|
||||
</DropdownMenu.Root>
|
||||
);
|
||||
}
|
@ -1,128 +0,0 @@
|
||||
import { NDKEvent } from '@nostr-dev-kit/ndk';
|
||||
import * as Popover from '@radix-ui/react-popover';
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { toast } from 'sonner';
|
||||
import { useArk } from '@libs/ark';
|
||||
import { ReactionIcon } from '@shared/icons';
|
||||
|
||||
const REACTIONS = [
|
||||
{
|
||||
content: '👏',
|
||||
img: '/clapping_hands.png',
|
||||
},
|
||||
{
|
||||
content: '🤪',
|
||||
img: '/face_with_tongue.png',
|
||||
},
|
||||
{
|
||||
content: '😮',
|
||||
img: '/face_with_open_mouth.png',
|
||||
},
|
||||
{
|
||||
content: '😢',
|
||||
img: '/crying_face.png',
|
||||
},
|
||||
{
|
||||
content: '🤡',
|
||||
img: '/clown_face.png',
|
||||
},
|
||||
];
|
||||
|
||||
export function NoteReaction({ event }: { event: NDKEvent }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [reaction, setReaction] = useState<string | null>(null);
|
||||
|
||||
const ark = useArk();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const getReactionImage = (content: string) => {
|
||||
const reaction: { img: string } = REACTIONS.find((el) => el.content === content);
|
||||
return reaction.img;
|
||||
};
|
||||
|
||||
const react = async (content: string) => {
|
||||
try {
|
||||
if (!ark.readyToSign) return navigate('/new/privkey');
|
||||
|
||||
setReaction(content);
|
||||
|
||||
// react
|
||||
await event.react(content);
|
||||
setOpen(false);
|
||||
} catch (e) {
|
||||
toast.error(e);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Popover.Root open={open} onOpenChange={setOpen}>
|
||||
<Popover.Trigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="group inline-flex h-7 w-7 items-center justify-center text-neutral-600 dark:text-neutral-400"
|
||||
>
|
||||
{reaction ? (
|
||||
<img src={getReactionImage(reaction)} alt={reaction} className="h-5 w-5" />
|
||||
) : (
|
||||
<ReactionIcon className="h-5 w-5 group-hover:text-blue-500" />
|
||||
)}
|
||||
</button>
|
||||
</Popover.Trigger>
|
||||
<Popover.Portal>
|
||||
<Popover.Content
|
||||
className="select-none rounded-md bg-neutral-200 px-1 py-1 text-sm will-change-[transform,opacity] data-[state=open]:data-[side=bottom]:animate-slideUpAndFade data-[state=open]:data-[side=left]:animate-slideRightAndFade data-[state=open]:data-[side=right]:animate-slideLeftAndFade data-[state=open]:data-[side=top]:animate-slideDownAndFade dark:bg-neutral-800"
|
||||
sideOffset={0}
|
||||
side="top"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => react('👏')}
|
||||
className="inline-flex h-8 w-8 items-center justify-center rounded backdrop-blur-xl hover:bg-white/10"
|
||||
>
|
||||
<img src="/clapping_hands.png" alt="Clapping Hands" className="h-6 w-6" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => react('🤪')}
|
||||
className="inline-flex h-7 w-7 items-center justify-center rounded backdrop-blur-xl hover:bg-white/10"
|
||||
>
|
||||
<img
|
||||
src="/face_with_tongue.png"
|
||||
alt="Face with Tongue"
|
||||
className="h-6 w-6"
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => react('😮')}
|
||||
className="inline-flex h-7 w-7 items-center justify-center rounded backdrop-blur-xl hover:bg-white/10"
|
||||
>
|
||||
<img
|
||||
src="/face_with_open_mouth.png"
|
||||
alt="Face with Open Mouth"
|
||||
className="h-6 w-6"
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => react('😢')}
|
||||
className="inline-flex h-7 w-7 items-center justify-center rounded backdrop-blur-xl hover:bg-white/10"
|
||||
>
|
||||
<img src="/crying_face.png" alt="Crying Face" className="h-6 w-6" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => react('🤡')}
|
||||
className="inline-flex h-7 w-7 items-center justify-center rounded backdrop-blur-xl hover:bg-white/10"
|
||||
>
|
||||
<img src="/clown_face.png" alt="Clown Face" className="h-6 w-6" />
|
||||
</button>
|
||||
</div>
|
||||
<Popover.Arrow className="fill-neutral-200 dark:fill-neutral-800" />
|
||||
</Popover.Content>
|
||||
</Popover.Portal>
|
||||
</Popover.Root>
|
||||
);
|
||||
}
|
@ -1,100 +0,0 @@
|
||||
import { NDKEvent } from '@nostr-dev-kit/ndk';
|
||||
import * as AlertDialog from '@radix-ui/react-alert-dialog';
|
||||
import * as Tooltip from '@radix-ui/react-tooltip';
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { toast } from 'sonner';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
import { useArk } from '@libs/ark';
|
||||
import { LoaderIcon, RepostIcon } from '@shared/icons';
|
||||
|
||||
export function NoteRepost({ event }: { event: NDKEvent }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isRepost, setIsRepost] = useState(false);
|
||||
|
||||
const ark = useArk();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const submit = async () => {
|
||||
try {
|
||||
if (!ark.readyToSign) return navigate('/new/privkey');
|
||||
|
||||
setIsLoading(true);
|
||||
|
||||
// repsot
|
||||
await event.repost(true);
|
||||
|
||||
// reset state
|
||||
setOpen(false);
|
||||
setIsRepost(true);
|
||||
|
||||
toast.success("You've reposted this post successfully");
|
||||
} catch (e) {
|
||||
setIsLoading(false);
|
||||
toast.error('Repost failed, try again later');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<AlertDialog.Root open={open} onOpenChange={setOpen}>
|
||||
<Tooltip.Root delayDuration={150}>
|
||||
<Tooltip.Trigger asChild>
|
||||
<AlertDialog.Trigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="group inline-flex h-7 w-7 items-center justify-center text-neutral-600 dark:text-neutral-400"
|
||||
>
|
||||
<RepostIcon
|
||||
className={twMerge(
|
||||
'h-5 w-5 group-hover:text-blue-600',
|
||||
isRepost ? 'text-blue-500' : ''
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
</AlertDialog.Trigger>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Portal>
|
||||
<Tooltip.Content className="-left-10 inline-flex h-7 select-none items-center justify-center rounded-md bg-neutral-200 px-3.5 text-sm text-neutral-900 will-change-[transform,opacity] data-[state=delayed-open]:data-[side=bottom]:animate-slideUpAndFade data-[state=delayed-open]:data-[side=left]:animate-slideRightAndFade data-[state=delayed-open]:data-[side=right]:animate-slideLeftAndFade data-[state=delayed-open]:data-[side=top]:animate-slideDownAndFade dark:bg-neutral-800 dark:text-neutral-100">
|
||||
Repost
|
||||
<Tooltip.Arrow className="fill-neutral-200 dark:fill-neutral-800" />
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Portal>
|
||||
</Tooltip.Root>
|
||||
<AlertDialog.Portal>
|
||||
<AlertDialog.Overlay className="fixed inset-0 z-50 bg-black/20 backdrop-blur-sm dark:bg-black/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-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">
|
||||
Reposted post will be visible to your followers, and you cannot undo this
|
||||
action.
|
||||
</AlertDialog.Description>
|
||||
</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 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-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" />
|
||||
) : (
|
||||
'Yes, repost'
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</AlertDialog.Content>
|
||||
</AlertDialog.Portal>
|
||||
</AlertDialog.Root>
|
||||
);
|
||||
}
|
@ -1,252 +0,0 @@
|
||||
import { webln } from '@getalby/sdk';
|
||||
import { SendPaymentResponse } from '@getalby/sdk/dist/types';
|
||||
import { NDKEvent } from '@nostr-dev-kit/ndk';
|
||||
import * as Dialog from '@radix-ui/react-dialog';
|
||||
import { invoke } from '@tauri-apps/api/primitives';
|
||||
import { message } from '@tauri-apps/plugin-dialog';
|
||||
import { QRCodeSVG } from 'qrcode.react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import CurrencyInput from 'react-currency-input-field';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useArk } from '@libs/ark';
|
||||
import { CancelIcon, ZapIcon } from '@shared/icons';
|
||||
import { compactNumber, displayNpub } from '@utils/formater';
|
||||
import { useProfile } from '@utils/hooks/useProfile';
|
||||
import { sendNativeNotification } from '@utils/notification';
|
||||
|
||||
export function NoteZap({ event }: { event: NDKEvent }) {
|
||||
const ark = useArk();
|
||||
const { user } = useProfile(event.pubkey);
|
||||
|
||||
const [walletConnectURL, setWalletConnectURL] = useState<string>(null);
|
||||
const [amount, setAmount] = useState<string>('21');
|
||||
const [zapMessage, setZapMessage] = useState<string>('');
|
||||
const [invoice, setInvoice] = useState<null | string>(null);
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [isCompleted, setIsCompleted] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const nwc = useRef(null);
|
||||
const navigate = useNavigate();
|
||||
|
||||
const createZapRequest = async () => {
|
||||
try {
|
||||
if (!ark.readyToSign) return navigate('/new/privkey');
|
||||
|
||||
const zapAmount = parseInt(amount) * 1000;
|
||||
const res = await event.zap(zapAmount, zapMessage);
|
||||
|
||||
if (!res)
|
||||
return await message('Cannot create zap request', {
|
||||
title: 'Zap',
|
||||
type: 'error',
|
||||
});
|
||||
|
||||
// user don't connect nwc, create QR Code for invoice
|
||||
if (!walletConnectURL) return setInvoice(res);
|
||||
|
||||
// user connect nwc
|
||||
nwc.current = new webln.NostrWebLNProvider({
|
||||
nostrWalletConnectUrl: walletConnectURL,
|
||||
});
|
||||
await nwc.current.enable();
|
||||
|
||||
// start loading
|
||||
setIsLoading(true);
|
||||
// send payment via nwc
|
||||
const send: SendPaymentResponse = await nwc.current.sendPayment(res);
|
||||
|
||||
if (send) {
|
||||
await sendNativeNotification(
|
||||
`You've tipped ${compactNumber.format(send.amount)} sats to ${
|
||||
user?.name || user?.display_name || user?.displayName
|
||||
}`
|
||||
);
|
||||
|
||||
// eose
|
||||
nwc.current.close();
|
||||
setIsCompleted(true);
|
||||
setIsLoading(false);
|
||||
|
||||
// reset after 3 secs
|
||||
const timeout = setTimeout(() => setIsCompleted(false), 3000);
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
} catch (e) {
|
||||
nwc.current.close();
|
||||
setIsLoading(false);
|
||||
await message(JSON.stringify(e), { title: 'Zap', type: 'error' });
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
async function getWalletConnectURL() {
|
||||
const uri: string = await invoke('secure_load', {
|
||||
key: `${ark.account.pubkey}-nwc`,
|
||||
});
|
||||
if (uri) setWalletConnectURL(uri);
|
||||
}
|
||||
|
||||
if (isOpen) getWalletConnectURL();
|
||||
|
||||
return () => {
|
||||
setAmount('21');
|
||||
setZapMessage('');
|
||||
setIsCompleted(false);
|
||||
setIsLoading(false);
|
||||
};
|
||||
}, [isOpen]);
|
||||
|
||||
return (
|
||||
<Dialog.Root open={isOpen} onOpenChange={setIsOpen}>
|
||||
<Dialog.Trigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="group inline-flex h-7 w-7 items-center justify-center text-neutral-600 dark:text-neutral-400"
|
||||
>
|
||||
<ZapIcon className="h-5 w-5 group-hover:text-blue-500" />
|
||||
</button>
|
||||
</Dialog.Trigger>
|
||||
<Dialog.Portal>
|
||||
<Dialog.Overlay className="fixed inset-0 z-50 bg-black/20 backdrop-blur-sm dark:bg-black/20" />
|
||||
<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-xl rounded-xl bg-white dark:bg-black">
|
||||
<div className="inline-flex w-full shrink-0 items-center justify-between px-5 py-3">
|
||||
<div className="w-6" />
|
||||
<Dialog.Title className="text-center font-semibold">
|
||||
Send tip to{' '}
|
||||
{user?.name || user?.displayName || displayNpub(event.pubkey, 16)}
|
||||
</Dialog.Title>
|
||||
<Dialog.Close className="inline-flex h-6 w-6 items-center justify-center rounded-md bg-neutral-100 dark:bg-neutral-900">
|
||||
<CancelIcon className="h-4 w-4" />
|
||||
</Dialog.Close>
|
||||
</div>
|
||||
<div className="overflow-y-auto overflow-x-hidden px-5 pb-5">
|
||||
{!invoice ? (
|
||||
<>
|
||||
<div className="relative flex h-40 flex-col">
|
||||
<div className="inline-flex h-full flex-1 items-center justify-center gap-1">
|
||||
<CurrencyInput
|
||||
placeholder="0"
|
||||
defaultValue={'21'}
|
||||
value={amount}
|
||||
decimalsLimit={2}
|
||||
min={0} // 0 sats
|
||||
max={10000} // 1M sats
|
||||
maxLength={10000} // 1M sats
|
||||
onValueChange={(value) => setAmount(value)}
|
||||
className="w-full flex-1 border-none bg-transparent text-right text-4xl font-semibold placeholder:text-neutral-600 focus:outline-none focus:ring-0 dark:text-neutral-400"
|
||||
/>
|
||||
<span className="w-full flex-1 text-left text-4xl font-semibold text-neutral-600 dark:text-neutral-400">
|
||||
sats
|
||||
</span>
|
||||
</div>
|
||||
<div className="inline-flex items-center justify-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAmount('69')}
|
||||
className="w-max rounded-full border border-neutral-200 bg-neutral-100 px-2.5 py-1 text-sm font-medium hover:bg-neutral-200 dark:border-neutral-800 dark:bg-neutral-900 dark:hover:bg-neutral-800"
|
||||
>
|
||||
69 sats
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAmount('100')}
|
||||
className="w-max rounded-full border border-neutral-200 bg-neutral-100 px-2.5 py-1 text-sm font-medium hover:bg-neutral-200 dark:border-neutral-800 dark:bg-neutral-900 dark:hover:bg-neutral-800"
|
||||
>
|
||||
100 sats
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAmount('200')}
|
||||
className="w-max rounded-full border border-neutral-200 bg-neutral-100 px-2.5 py-1 text-sm font-medium hover:bg-neutral-200 dark:border-neutral-800 dark:bg-neutral-900 dark:hover:bg-neutral-800"
|
||||
>
|
||||
200 sats
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAmount('500')}
|
||||
className="w-max rounded-full border border-neutral-200 bg-neutral-100 px-2.5 py-1 text-sm font-medium hover:bg-neutral-200 dark:border-neutral-800 dark:bg-neutral-900 dark:hover:bg-neutral-800"
|
||||
>
|
||||
500 sats
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAmount('1000')}
|
||||
className="w-max rounded-full border border-neutral-200 bg-neutral-100 px-2.5 py-1 text-sm font-medium hover:bg-neutral-200 dark:border-neutral-800 dark:bg-neutral-900 dark:hover:bg-neutral-800"
|
||||
>
|
||||
1K sats
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 flex w-full flex-col gap-2">
|
||||
<input
|
||||
name="zapMessage"
|
||||
value={zapMessage}
|
||||
onChange={(e) => setZapMessage(e.target.value)}
|
||||
spellCheck={false}
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
autoCapitalize="off"
|
||||
placeholder="Enter message (optional)"
|
||||
className="w-full resize-none rounded-lg border-transparent bg-neutral-100 px-3 py-3 !outline-none placeholder:text-neutral-600 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:bg-neutral-900 dark:text-neutral-400"
|
||||
/>
|
||||
<div className="flex flex-col gap-2">
|
||||
{walletConnectURL ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => createZapRequest()}
|
||||
className="inline-flex h-11 w-full items-center justify-center rounded-lg bg-blue-500 px-4 font-medium text-white hover:bg-blue-600"
|
||||
>
|
||||
{isCompleted ? (
|
||||
<p className="leading-tight">Successfully zapped</p>
|
||||
) : isLoading ? (
|
||||
<span className="flex flex-col">
|
||||
<p className="leading-tight">Waiting for approval</p>
|
||||
<p className="text-xs leading-tight text-neutral-100">
|
||||
Go to your wallet and approve payment request
|
||||
</p>
|
||||
</span>
|
||||
) : (
|
||||
<span className="flex flex-col">
|
||||
<p className="leading-tight">Send zap</p>
|
||||
<p className="text-xs leading-tight text-neutral-100">
|
||||
You're using nostr wallet connect
|
||||
</p>
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => createZapRequest()}
|
||||
className="inline-flex h-11 w-full items-center justify-center rounded-lg bg-blue-500 px-4 font-medium text-white hover:bg-blue-600"
|
||||
>
|
||||
Create Lightning invoice
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="mt-3 flex flex-col items-center justify-center gap-4">
|
||||
<div className="rounded-md bg-neutral-100 p-3 dark:bg-neutral-900">
|
||||
<QRCodeSVG value={invoice} size={256} />
|
||||
</div>
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
<h3 className="text-lg font-medium">Scan to zap</h3>
|
||||
<span className="text-center text-sm text-neutral-600 dark:text-neutral-400">
|
||||
You must use Bitcoin wallet which support Lightning
|
||||
<br />
|
||||
such as: Blue Wallet, Bitkit, Phoenix,...
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
</Dialog.Portal>
|
||||
</Dialog.Root>
|
||||
);
|
||||
}
|
@ -1,73 +0,0 @@
|
||||
import { NDKEvent } from '@nostr-dev-kit/ndk';
|
||||
import { memo } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { User } from '@shared/user';
|
||||
import { NoteActions } from './actions';
|
||||
|
||||
export function ArticleNote({ event }: { event: NDKEvent }) {
|
||||
const getMetadata = () => {
|
||||
const title = event.tags.find((tag) => tag[0] === 'title')?.[1];
|
||||
const image = event.tags.find((tag) => tag[0] === 'image')?.[1];
|
||||
const summary = event.tags.find((tag) => tag[0] === 'summary')?.[1];
|
||||
|
||||
let publishedAt: Date | string | number = event.tags.find(
|
||||
(tag) => tag[0] === 'published_at'
|
||||
)?.[1];
|
||||
if (publishedAt) {
|
||||
publishedAt = new Date(parseInt(publishedAt) * 1000).toLocaleDateString('en-US');
|
||||
} else {
|
||||
publishedAt = new Date(event.created_at * 1000).toLocaleDateString('en-US');
|
||||
}
|
||||
|
||||
return {
|
||||
title,
|
||||
image,
|
||||
publishedAt,
|
||||
summary,
|
||||
};
|
||||
};
|
||||
|
||||
const metadata = getMetadata();
|
||||
|
||||
return (
|
||||
<div className="mb-3 h-min w-full px-3">
|
||||
<div className="relative flex flex-col gap-2 overflow-hidden rounded-xl bg-neutral-50 pt-3 dark:bg-neutral-950">
|
||||
<User pubkey={event.pubkey} time={event.created_at} eventId={event.id} />
|
||||
<div className="px-3">
|
||||
<Link
|
||||
to={`/notes/article/${event.id}`}
|
||||
preventScrollReset={true}
|
||||
className="flex w-full flex-col rounded-lg bg-neutral-100 dark:bg-neutral-900"
|
||||
>
|
||||
{metadata.image && (
|
||||
<img
|
||||
src={metadata.image}
|
||||
alt={metadata.title}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
style={{ contentVisibility: 'auto' }}
|
||||
className="h-auto w-full rounded-t-lg object-cover"
|
||||
/>
|
||||
)}
|
||||
<div className="flex flex-col gap-1 rounded-b-lg rounded-t-lg bg-neutral-100 px-3 py-3 dark:bg-neutral-900">
|
||||
<h5 className="break-all font-semibold text-neutral-900 dark:text-neutral-100">
|
||||
{metadata.title}
|
||||
</h5>
|
||||
{metadata.summary ? (
|
||||
<p className="line-clamp-3 break-all text-sm text-neutral-600 dark:text-neutral-400">
|
||||
{metadata.summary}
|
||||
</p>
|
||||
) : null}
|
||||
<span className="mt-2.5 text-sm text-neutral-600 dark:text-neutral-400">
|
||||
{metadata.publishedAt.toString()}
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
<NoteActions event={event} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const MemoizedArticleNote = memo(ArticleNote);
|
@ -1,38 +0,0 @@
|
||||
import { NoteSkeleton } from '@shared/notes';
|
||||
import { User } from '@shared/user';
|
||||
import { useEvent } from '@utils/hooks/useEvent';
|
||||
|
||||
export function ChildNote({ id, isRoot }: { id: string; isRoot?: boolean }) {
|
||||
const { isFetching, isError, data } = useEvent(id);
|
||||
|
||||
if (isFetching) {
|
||||
return <NoteSkeleton />;
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
<div className="relative flex gap-3">
|
||||
<div className="relative flex-1 rounded-md bg-neutral-200 px-2 py-2 dark:bg-neutral-800">
|
||||
Failed to fetch event
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative flex gap-3">
|
||||
<div className="relative flex-1 rounded-md bg-neutral-200 px-2 py-2 dark:bg-neutral-800">
|
||||
<div className="absolute right-0 top-[18px] h-3 w-3 -translate-y-1/2 translate-x-1/2 rotate-45 transform bg-neutral-200 dark:bg-neutral-800"></div>
|
||||
<div className="break-p mt-6 line-clamp-3 select-text leading-normal text-neutral-900 dark:text-neutral-100">
|
||||
{data?.content}
|
||||
</div>
|
||||
</div>
|
||||
<User
|
||||
pubkey={data?.pubkey}
|
||||
time={data?.created_at}
|
||||
variant="childnote"
|
||||
subtext={isRoot ? 'posted' : 'replied'}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
@ -1,85 +0,0 @@
|
||||
import { NDKEvent } from '@nostr-dev-kit/ndk';
|
||||
import { downloadDir } from '@tauri-apps/api/path';
|
||||
import { download } from '@tauri-apps/plugin-upload';
|
||||
import { MediaPlayer, MediaProvider } from '@vidstack/react';
|
||||
import {
|
||||
DefaultVideoLayout,
|
||||
defaultLayoutIcons,
|
||||
} from '@vidstack/react/player/layouts/default';
|
||||
import { memo } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { DownloadIcon } from '@shared/icons';
|
||||
import { NoteActions } from '@shared/notes';
|
||||
import { User } from '@shared/user';
|
||||
import { fileType } from '@utils/nip94';
|
||||
|
||||
export function FileNote({ event }: { event: NDKEvent }) {
|
||||
const downloadImage = async (url: string) => {
|
||||
const downloadDirPath = await downloadDir();
|
||||
const filename = url.substring(url.lastIndexOf('/') + 1);
|
||||
return await download(url, downloadDirPath + `/${filename}`);
|
||||
};
|
||||
|
||||
const renderFileType = () => {
|
||||
const url = event.tags.find((el) => el[0] === 'url')[1];
|
||||
const type = fileType(url);
|
||||
|
||||
switch (type) {
|
||||
case 'image':
|
||||
return (
|
||||
<div className="group relative">
|
||||
<img
|
||||
src={url}
|
||||
alt={url}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
style={{ contentVisibility: 'auto' }}
|
||||
className="h-auto w-full object-cover"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => downloadImage(url)}
|
||||
className="absolute right-2 top-2 hidden h-10 w-10 items-center justify-center rounded-lg bg-black/50 backdrop-blur-xl group-hover:inline-flex hover:bg-blue-500"
|
||||
>
|
||||
<DownloadIcon className="h-5 w-5 text-white" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
case 'video':
|
||||
return (
|
||||
<MediaPlayer
|
||||
src={url}
|
||||
className="w-full overflow-hidden rounded-lg"
|
||||
aspectRatio="16/9"
|
||||
load="visible"
|
||||
>
|
||||
<MediaProvider />
|
||||
<DefaultVideoLayout icons={defaultLayoutIcons} />
|
||||
</MediaPlayer>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<Link
|
||||
to={url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-blue-500 hover:text-blue-600"
|
||||
>
|
||||
{url}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mb-3 h-min w-full px-3">
|
||||
<div className="relative flex flex-col gap-2 overflow-hidden rounded-xl bg-neutral-50 pt-3 dark:bg-neutral-950">
|
||||
<User pubkey={event.pubkey} time={event.created_at} eventId={event.id} />
|
||||
<div className="relative mt-2">{renderFileType()}</div>
|
||||
<NoteActions event={event} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const MemoizedFileNote = memo(FileNote);
|
@ -1,28 +0,0 @@
|
||||
export * from './text';
|
||||
export * from './repost';
|
||||
export * from './file';
|
||||
export * from './article';
|
||||
export * from './child';
|
||||
export * from './notify';
|
||||
export * from './unknown';
|
||||
export * from './skeleton';
|
||||
export * from './actions';
|
||||
export * from './actions/reaction';
|
||||
export * from './actions/repost';
|
||||
export * from './actions/zap';
|
||||
export * from './actions/more';
|
||||
export * from './preview/image';
|
||||
export * from './preview/link';
|
||||
export * from './preview/video';
|
||||
export * from './replies/form';
|
||||
export * from './replies/item';
|
||||
export * from './replies/list';
|
||||
export * from './replies/sub';
|
||||
export * from './replies/replyMediaUploader';
|
||||
export * from './mentions/note';
|
||||
export * from './mentions/user';
|
||||
export * from './mentions/hashtag';
|
||||
export * from './mentions/invoice';
|
||||
export * from './kinds/text';
|
||||
export * from './kinds/article';
|
||||
export * from './kinds/file';
|
@ -1,24 +0,0 @@
|
||||
import { memo } from 'react';
|
||||
import { useRichContent } from '@utils/hooks/useRichContent';
|
||||
|
||||
export function TextKind({ content, textmode }: { content: string; textmode?: boolean }) {
|
||||
const { parsedContent } = useRichContent(content, textmode);
|
||||
|
||||
if (textmode) {
|
||||
return (
|
||||
<div className="line-clamp-3 select-text leading-normal text-neutral-900 dark:text-neutral-100">
|
||||
{parsedContent}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-w-0 px-3">
|
||||
<div className="break-p select-text leading-normal text-neutral-900 dark:text-neutral-100">
|
||||
{parsedContent}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const MemoizedTextKind = memo(TextKind);
|
@ -1,78 +0,0 @@
|
||||
import { NDKEvent, NDKKind } from '@nostr-dev-kit/ndk';
|
||||
import { memo } from 'react';
|
||||
import {
|
||||
MemoizedArticleKind,
|
||||
MemoizedFileKind,
|
||||
MemoizedTextKind,
|
||||
NoteSkeleton,
|
||||
} from '@shared/notes';
|
||||
import { User } from '@shared/user';
|
||||
import { WIDGET_KIND } from '@utils/constants';
|
||||
import { useEvent } from '@utils/hooks/useEvent';
|
||||
import { useWidget } from '@utils/hooks/useWidget';
|
||||
|
||||
export const MentionNote = memo(function MentionNote({
|
||||
id,
|
||||
editing,
|
||||
}: {
|
||||
id: string;
|
||||
editing?: boolean;
|
||||
}) {
|
||||
const { isFetching, isError, data } = useEvent(id);
|
||||
const { addWidget } = useWidget();
|
||||
|
||||
const renderKind = (event: NDKEvent) => {
|
||||
switch (event.kind) {
|
||||
case NDKKind.Text:
|
||||
return <MemoizedTextKind content={event.content} textmode />;
|
||||
case NDKKind.Article:
|
||||
return <MemoizedArticleKind id={event.id} tags={event.tags} />;
|
||||
case 1063:
|
||||
return <MemoizedFileKind tags={event.tags} />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
if (isFetching) {
|
||||
return (
|
||||
<div className="w-full cursor-default rounded-lg bg-neutral-100 p-3 dark:bg-neutral-900">
|
||||
<NoteSkeleton />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
<div className="w-full cursor-default rounded-lg bg-neutral-100 p-3 dark:bg-neutral-900">
|
||||
Failed to fetch event
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="my-2 flex w-full cursor-default flex-col gap-1 rounded-lg bg-neutral-100 dark:bg-neutral-900">
|
||||
<div className="mt-3 px-3">
|
||||
<User pubkey={data.pubkey} time={data.created_at} variant="mention" />
|
||||
</div>
|
||||
<div className="mt-1 px-3 pb-3">
|
||||
{renderKind(data)}
|
||||
{!editing ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
addWidget.mutate({
|
||||
kind: WIDGET_KIND.thread,
|
||||
title: 'Thread',
|
||||
content: data.id,
|
||||
})
|
||||
}
|
||||
className="mt-2 text-blue-500 hover:text-blue-600"
|
||||
>
|
||||
Show more
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
@ -1,155 +0,0 @@
|
||||
import { NDKEvent, NDKKind } from '@nostr-dev-kit/ndk';
|
||||
import { memo } from 'react';
|
||||
import { useArk } from '@libs/ark';
|
||||
import { ReplyIcon, RepostIcon } from '@shared/icons';
|
||||
import { ChildNote, TextKind } from '@shared/notes';
|
||||
import { User } from '@shared/user';
|
||||
import { WIDGET_KIND } from '@utils/constants';
|
||||
import { formatCreatedAt } from '@utils/formater';
|
||||
import { useWidget } from '@utils/hooks/useWidget';
|
||||
|
||||
export function NotifyNote({ event }: { event: NDKEvent }) {
|
||||
const ark = useArk();
|
||||
const { addWidget } = useWidget();
|
||||
|
||||
const thread = ark.getEventThread({ tags: event.tags });
|
||||
const createdAt = formatCreatedAt(event.created_at, false);
|
||||
|
||||
if (event.kind === NDKKind.Reaction) {
|
||||
return (
|
||||
<div className="mb-3 h-min w-full px-3">
|
||||
<div className="flex flex-col gap-2 rounded-xl bg-neutral-50 p-3 dark:bg-neutral-950">
|
||||
<div className="flex h-10 items-center justify-between">
|
||||
<div className="relative flex w-full items-center gap-2 px-3 pt-2">
|
||||
<div className="absolute -left-0.5 -top-0.5 inline-flex h-6 w-6 shrink-0 items-center justify-center rounded-md bg-blue-100 text-xs ring-2 ring-neutral-50 dark:bg-blue-900 dark:ring-neutral-950">
|
||||
{event.content === '+' ? '👍' : event.content}
|
||||
</div>
|
||||
<div className="flex flex-1 items-center justify-between">
|
||||
<div className="inline-flex items-center gap-1.5">
|
||||
<User pubkey={event.pubkey} variant="notify" />
|
||||
<p className="text-neutral-700 dark:text-neutral-300">reacted</p>
|
||||
</div>
|
||||
<div className="text-neutral-500 dark:text-neutral-400">{createdAt}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="w-full px-3">
|
||||
<div className="flex h-min w-full flex-col gap-3 rounded-lg bg-neutral-100 p-3 dark:bg-neutral-900">
|
||||
{thread.rootEventId ? <ChildNote id={thread.rootEventId} /> : null}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
addWidget.mutate({
|
||||
kind: WIDGET_KIND.thread,
|
||||
title: 'Thread',
|
||||
content: thread.rootEventId,
|
||||
})
|
||||
}
|
||||
className="self-start text-blue-500 hover:text-blue-600"
|
||||
>
|
||||
Show original post
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (event.kind === NDKKind.Repost) {
|
||||
return (
|
||||
<div className="mb-3 h-min w-full px-3">
|
||||
<div className="flex flex-col gap-2 rounded-xl bg-neutral-50 p-3 dark:bg-neutral-950">
|
||||
<div className="flex h-10 items-center justify-between">
|
||||
<div className="relative flex w-full items-center gap-2 px-3 pt-2">
|
||||
<div className="absolute -left-0.5 -top-0.5 inline-flex h-6 w-6 shrink-0 items-center justify-center rounded-md bg-teal-500 text-xs ring-2 ring-neutral-50 dark:ring-neutral-950">
|
||||
<RepostIcon className="h-4 w-4 text-white" />
|
||||
</div>
|
||||
<div className="flex flex-1 items-center justify-between">
|
||||
<div className="inline-flex items-center gap-1.5">
|
||||
<User pubkey={event.pubkey} variant="notify" />
|
||||
<p className="text-neutral-700 dark:text-neutral-300">reposted</p>
|
||||
</div>
|
||||
<div className="text-neutral-500 dark:text-neutral-400">{createdAt}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="w-full px-3">
|
||||
<div className="flex h-min w-full flex-col gap-3 rounded-lg bg-neutral-100 p-3 dark:bg-neutral-900">
|
||||
{thread.rootEventId ? <ChildNote id={thread.rootEventId} /> : null}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
addWidget.mutate({
|
||||
kind: WIDGET_KIND.thread,
|
||||
title: 'Thread',
|
||||
content: thread.rootEventId,
|
||||
})
|
||||
}
|
||||
className="self-start text-blue-500 hover:text-blue-600"
|
||||
>
|
||||
Show original post
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (event.kind === NDKKind.Text) {
|
||||
return (
|
||||
<div className="mb-3 h-min w-full px-3">
|
||||
<div className="flex flex-col gap-2 rounded-xl bg-neutral-50 p-3 dark:bg-neutral-950">
|
||||
<div className="flex h-10 items-center justify-between">
|
||||
<div className="relative flex w-full items-center gap-2 px-3 pt-2">
|
||||
<div className="absolute -left-0.5 -top-0.5 inline-flex h-6 w-6 shrink-0 items-center justify-center rounded-md bg-blue-500 text-xs ring-2 ring-neutral-50 dark:ring-neutral-950">
|
||||
<ReplyIcon className="h-4 w-4 text-white" />
|
||||
</div>
|
||||
<div className="flex flex-1 items-center justify-between">
|
||||
<div className="inline-flex items-center gap-1.5">
|
||||
<User pubkey={event.pubkey} variant="notify" />
|
||||
<p className="text-neutral-700 dark:text-neutral-300">replied</p>
|
||||
</div>
|
||||
<div className="text-neutral-500 dark:text-neutral-400">{createdAt}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="w-full px-3">
|
||||
<div className="flex h-min w-full flex-col gap-3 rounded-lg bg-neutral-100 p-3 dark:bg-neutral-900">
|
||||
{thread?.replyEventId ? (
|
||||
<ChildNote id={thread?.replyEventId} />
|
||||
) : thread?.rootEventId ? (
|
||||
<ChildNote id={thread?.rootEventId} isRoot />
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
addWidget.mutate({
|
||||
kind: WIDGET_KIND.thread,
|
||||
title: 'Thread',
|
||||
content: thread.replyEventId
|
||||
? thread.replyEventId
|
||||
: thread.rootEventId,
|
||||
})
|
||||
}
|
||||
className="self-start text-blue-500 hover:text-blue-600"
|
||||
>
|
||||
Show full thread
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<TextKind content={event.content} textmode />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export const MemoizedNotifyNote = memo(NotifyNote);
|
@ -1,58 +0,0 @@
|
||||
import { NDKEvent } from '@nostr-dev-kit/ndk';
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { toast } from 'sonner';
|
||||
import { useArk } from '@libs/ark';
|
||||
import { LoaderIcon } from '@shared/icons';
|
||||
import { ReplyMediaUploader } from '@shared/notes';
|
||||
|
||||
export function NoteReplyForm({ rootEvent }: { rootEvent: NDKEvent }) {
|
||||
const ark = useArk();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [value, setValue] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const submit = async () => {
|
||||
try {
|
||||
if (!ark.readyToSign) return navigate('/new/privkey');
|
||||
setLoading(true);
|
||||
|
||||
// publish event
|
||||
const publish = await ark.replyTo({ content: value, event: rootEvent });
|
||||
|
||||
if (publish) {
|
||||
toast.success(`Broadcasted to ${publish.size} relays successfully.`);
|
||||
|
||||
// reset state
|
||||
setValue('');
|
||||
setLoading(false);
|
||||
}
|
||||
} catch (e) {
|
||||
setLoading(false);
|
||||
toast.error(e);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mt-3 flex flex-col rounded-xl bg-neutral-50 dark:bg-neutral-950">
|
||||
<textarea
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
placeholder="Reply to this post..."
|
||||
className="h-28 w-full resize-none rounded-t-xl border-transparent bg-neutral-100 px-5 py-4 text-neutral-900 !outline-none placeholder:text-neutral-600 focus:border-blue-500 focus:ring focus:ring-blue-200 dark:bg-neutral-900 dark:text-neutral-100 dark:placeholder:text-neutral-400 dark:focus:ring-blue-800"
|
||||
spellCheck={false}
|
||||
/>
|
||||
<div className="inline-flex items-center justify-end gap-2 rounded-b-xl p-2">
|
||||
<ReplyMediaUploader setValue={setValue} />
|
||||
<button
|
||||
onClick={() => submit()}
|
||||
disabled={value.length === 0 ? true : false}
|
||||
className="inline-flex h-9 w-20 items-center justify-center rounded-lg bg-blue-500 text-white hover:bg-blue-600 disabled:opacity-50"
|
||||
>
|
||||
{loading ? <LoaderIcon className="h-4 w-4 animate-spin" /> : 'Reply'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user