diff --git a/src/app.tsx b/src/app.tsx index 201bae85..a312965f 100644 --- a/src/app.tsx +++ b/src/app.tsx @@ -6,6 +6,7 @@ import { ReactFlowProvider } from 'reactflow'; import { AuthCreateScreen } from '@app/auth/create'; import { AuthImportScreen } from '@app/auth/import'; import { OnboardingScreen } from '@app/auth/onboarding'; +import { ChatsScreen } from '@app/chats'; import { ErrorScreen } from '@app/error'; import { ExploreScreen } from '@app/explore'; @@ -83,13 +84,6 @@ export default function App() { return { Component: UserScreen }; }, }, - { - path: 'chats/:pubkey', - async lazy() { - const { ChatScreen } = await import('@app/chats'); - return { Component: ChatScreen }; - }, - }, { path: 'notifications', async lazy() { @@ -104,15 +98,6 @@ export default function App() { return { Component: NWCScreen }; }, }, - { - path: 'explore', - element: ( - - - - ), - errorElement: , - }, { path: 'relays', async lazy() { @@ -135,6 +120,29 @@ export default function App() { return { Component: CommunitiesScreen }; }, }, + { + path: 'explore', + element: ( + + + + ), + errorElement: , + }, + { + path: 'chats', + element: , + errorElement: , + children: [ + { + path: 'chat/:pubkey', + async lazy() { + const { ChatScreen } = await import('@app/chats/chat'); + return { Component: ChatScreen }; + }, + }, + ], + }, ], }, { diff --git a/src/app/chats/chat.tsx b/src/app/chats/chat.tsx new file mode 100644 index 00000000..8d9d504c --- /dev/null +++ b/src/app/chats/chat.tsx @@ -0,0 +1,108 @@ +import { NDKEvent, NDKSubscription } from '@nostr-dev-kit/ndk'; +import { useQuery } from '@tanstack/react-query'; +import { useCallback, useEffect, useRef } from 'react'; +import { useParams } from 'react-router-dom'; +import { VList, VListHandle } from 'virtua'; + +import { ChatMessageForm } from '@app/chats/components/messages/form'; +import { ChatMessageItem } from '@app/chats/components/messages/item'; + +import { useNDK } from '@libs/ndk/provider'; +import { useStorage } from '@libs/storage/provider'; + +import { LoaderIcon } from '@shared/icons'; + +import { useStronghold } from '@stores/stronghold'; + +import { useNostr } from '@utils/hooks/useNostr'; + +export function ChatScreen() { + const listRef = useRef(null); + const userPrivkey = useStronghold((state) => state.privkey); + + const { db } = useStorage(); + const { ndk } = useNDK(); + const { pubkey } = useParams(); + const { fetchNIP04Messages } = useNostr(); + const { status, data } = useQuery(['nip04-dm', pubkey], async () => { + return await fetchNIP04Messages(pubkey); + }); + + const renderItem = useCallback( + (message: NDKEvent) => { + return ( + + ); + }, + [data] + ); + + useEffect(() => { + if (data.length > 0) listRef.current?.scrollToIndex(data.length); + }, [data]); + + useEffect(() => { + const sub: NDKSubscription = ndk.subscribe( + { + kinds: [4], + authors: [db.account.pubkey], + '#p': [pubkey], + since: Math.floor(Date.now() / 1000), + }, + { + closeOnEose: false, + } + ); + + sub.addListener('event', (event) => { + console.log(event); + }); + + return () => { + sub.stop(); + }; + }, [pubkey]); + + return ( +
+
+
+
+
+ {status === 'loading' ? ( +
+
+ +

Loading messages

+
+
+ ) : data.length === 0 ? ( +
+

🙌

+

+ You two didn't talk yet, let's send first message +

+
+ ) : ( + + {data.map((message) => renderItem(message))} + + )} +
+
+ +
+
+
+
+
+ ); +} diff --git a/src/app/chats/components/item.tsx b/src/app/chats/components/item.tsx index d692fdbe..db1c7227 100644 --- a/src/app/chats/components/item.tsx +++ b/src/app/chats/components/item.tsx @@ -1,13 +1,30 @@ +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 { Image } from '@shared/image'; +import { useDecryptMessage } from '@app/chats/hooks/useDecryptMessage'; +import { useStorage } from '@libs/storage/provider'; + +import { useStronghold } from '@stores/stronghold'; + +import { formatCreatedAt } from '@utils/createdAt'; import { useProfile } from '@utils/hooks/useProfile'; import { displayNpub } from '@utils/shortenKey'; -export function ChatsListItem({ pubkey }: { pubkey: string }) { - const { status, user } = useProfile(pubkey); +export const ChatListItem = memo(function ChatListItem({ event }: { event: NDKEvent }) { + const { db } = useStorage(); + const { status, user } = useProfile(event.pubkey); + + const privkey = useStronghold((state) => state.privkey); + const decryptedContent = useDecryptMessage(event, db.account.pubkey, privkey); + + const createdAt = formatCreatedAt(event.created_at, true); + const svgURI = + 'data:image/svg+xml;utf8,' + encodeURIComponent(minidenticon(event.pubkey, 90, 50)); if (status === 'loading') { return ( @@ -20,30 +37,48 @@ export function ChatsListItem({ pubkey }: { pubkey: string }) { return ( twMerge( - 'flex h-10 items-center gap-2.5 rounded-r-lg border-l-2 pl-4 pr-3', + 'flex items-center gap-2.5 px-3 py-2 hover:bg-white/10', isActive ? 'border-fuchsia-500 bg-white/5 text-white' : 'border-transparent text-white/70' ) } > - {pubkey} -
-
+ + + + {event.pubkey} + + +
+
{user?.name || user?.display_name || user?.displayName || - displayNpub(pubkey, 16)} + displayNpub(event.pubkey, 16)}
+
+

+ {decryptedContent} +

+

{createdAt}

+
); -} +}); diff --git a/src/app/chats/components/list.tsx b/src/app/chats/components/list.tsx deleted file mode 100644 index 8335b7b5..00000000 --- a/src/app/chats/components/list.tsx +++ /dev/null @@ -1,54 +0,0 @@ -import { useQuery } from '@tanstack/react-query'; -import { useCallback } from 'react'; - -import { ChatsListItem } from '@app/chats/components/item'; -import { NewMessageModal } from '@app/chats/components/modal'; -import { UnknownsModal } from '@app/chats/components/unknowns'; - -import { useStorage } from '@libs/storage/provider'; - -import { LoaderIcon } from '@shared/icons'; - -import { useNostr } from '@utils/hooks/useNostr'; - -export function ChatsList() { - const { db } = useStorage(); - const { fetchNIP04Chats } = useNostr(); - const { status, data: chats } = useQuery( - ['nip04-chats'], - async () => { - return await fetchNIP04Chats(); - }, - { refetchOnWindowFocus: false } - ); - - const renderItem = useCallback( - (item: string) => { - if (db.account.pubkey !== item) { - return ; - } - }, - [chats] - ); - - if (status === 'loading') { - return ( -
-
-
- -
-
Loading messages...
-
-
- ); - } - - return ( -
- {chats?.follows?.map((item) => renderItem(item))} - {chats?.unknowns?.length > 0 && } - -
- ); -} diff --git a/src/app/chats/components/modal.tsx b/src/app/chats/components/modal.tsx index 53aa5e71..e8314656 100644 --- a/src/app/chats/components/modal.tsx +++ b/src/app/chats/components/modal.tsx @@ -23,17 +23,15 @@ export function NewMessageModal() { - +
diff --git a/src/app/chats/components/sidebar.tsx b/src/app/chats/components/sidebar.tsx deleted file mode 100644 index 906647b3..00000000 --- a/src/app/chats/components/sidebar.tsx +++ /dev/null @@ -1,52 +0,0 @@ -import { Link } from 'react-router-dom'; - -import { Image } from '@shared/image'; -import { NIP05 } from '@shared/nip05'; - -import { useProfile } from '@utils/hooks/useProfile'; -import { displayNpub } from '@utils/shortenKey'; - -export function ChatSidebar({ pubkey }: { pubkey: string }) { - const { user } = useProfile(pubkey); - - return ( -
-
-
- {pubkey} -
-
-
-

- {user?.name || user?.display_name || user?.displayName} -

- {user?.nip05 ? ( - - ) : ( - - {displayNpub(pubkey, 16)} - - )} -
-
-

{user?.bio || user?.about}

- - View full profile - -
-
-
-
- ); -} diff --git a/src/app/chats/components/unknowns.tsx b/src/app/chats/components/unknowns.tsx deleted file mode 100644 index d04ab9dc..00000000 --- a/src/app/chats/components/unknowns.tsx +++ /dev/null @@ -1,79 +0,0 @@ -import * as Dialog from '@radix-ui/react-dialog'; -import { useState } from 'react'; -import { useNavigate } from 'react-router-dom'; - -import { CancelIcon, StrangersIcon } from '@shared/icons'; -import { User } from '@shared/user'; - -import { compactNumber } from '@utils/number'; - -export function UnknownsModal({ data }: { data: string[] }) { - const navigate = useNavigate(); - const [open, setOpen] = useState(false); - - const openChat = (pubkey: string) => { - setOpen(false); - navigate(`/chats/${pubkey}`); - }; - - return ( - - - - - - - -
-
-
-
- - {data.length} unknowns - - - - -
- - All messages from people you not follow - -
-
-
- {data.map((pubkey) => ( -
- -
- -
-
- ))} -
-
-
-
-
- ); -} diff --git a/src/app/chats/hooks/useDecryptMessage.tsx b/src/app/chats/hooks/useDecryptMessage.tsx index 9edaa56e..8be74007 100644 --- a/src/app/chats/hooks/useDecryptMessage.tsx +++ b/src/app/chats/hooks/useDecryptMessage.tsx @@ -2,24 +2,24 @@ import { NDKEvent } from '@nostr-dev-kit/ndk'; import { nip04 } from 'nostr-tools'; import { useEffect, useState } from 'react'; -export function useDecryptMessage( - message: NDKEvent, - userPubkey: string, - userPriv: string -) { +export function useDecryptMessage(message: NDKEvent, pubkey: string, privkey: string) { const [content, setContent] = useState(message.content); useEffect(() => { - async function decrypt() { - const pubkey = - userPubkey === message.pubkey - ? message.tags.find((el) => el[0] === 'p')[1] - : message.pubkey; - const result = await nip04.decrypt(userPriv, pubkey, message.content); - setContent(result); + async function decryptContent() { + try { + const sender = + pubkey === message.pubkey + ? message.tags.find((el) => el[0] === 'p')[1] + : message.pubkey; + const result = await nip04.decrypt(privkey, sender, message.content); + setContent(result); + } catch (e) { + console.error(e); + } } - decrypt().catch(console.error); + decryptContent(); }, []); return content; diff --git a/src/app/chats/index.tsx b/src/app/chats/index.tsx index c4bd0dd7..8f3690f1 100644 --- a/src/app/chats/index.tsx +++ b/src/app/chats/index.tsx @@ -1,111 +1,55 @@ -import { NDKEvent, NDKSubscription } from '@nostr-dev-kit/ndk'; +import { NDKEvent } from '@nostr-dev-kit/ndk'; import { useQuery } from '@tanstack/react-query'; -import { useCallback, useEffect, useRef } from 'react'; -import { useParams } from 'react-router-dom'; -import { VList, VListHandle } from 'virtua'; +import { useCallback } from 'react'; +import { Outlet } from 'react-router-dom'; -import { ChatMessageForm } from '@app/chats/components/messages/form'; -import { ChatMessageItem } from '@app/chats/components/messages/item'; -import { ChatSidebar } from '@app/chats/components/sidebar'; +import { ChatListItem } from '@app/chats/components/item'; -import { useNDK } from '@libs/ndk/provider'; import { useStorage } from '@libs/storage/provider'; import { LoaderIcon } from '@shared/icons'; -import { useStronghold } from '@stores/stronghold'; - import { useNostr } from '@utils/hooks/useNostr'; -export function ChatScreen() { - const listRef = useRef(null); - const userPrivkey = useStronghold((state) => state.privkey); - +export function ChatsScreen() { const { db } = useStorage(); - const { ndk } = useNDK(); - const { pubkey } = useParams(); - const { fetchNIP04Messages } = useNostr(); - const { status, data } = useQuery(['nip04-dm', pubkey], async () => { - return await fetchNIP04Messages(pubkey); - }); + const { getAllNIP04Chats } = useNostr(); + const { status, data } = useQuery( + ['nip04-chats'], + async () => { + return await getAllNIP04Chats(); + }, + { refetchOnWindowFocus: false } + ); const renderItem = useCallback( - (message: NDKEvent) => { - return ( - - ); + (event: NDKEvent) => { + if (db.account.pubkey !== event.pubkey) { + return ; + } }, [data] ); - useEffect(() => { - if (data.length > 0) listRef.current?.scrollToIndex(data.length); - }, [data]); - - useEffect(() => { - const sub: NDKSubscription = ndk.subscribe( - { - kinds: [4], - authors: [db.account.pubkey], - '#p': [pubkey], - since: Math.floor(Date.now() / 1000), - }, - { - closeOnEose: false, - } - ); - - sub.addListener('event', (event) => { - console.log(event); - }); - - return () => { - sub.stop(); - }; - }, [pubkey]); - return ( -
-
-
-
-
- {status === 'loading' ? ( -
-
- -

Loading messages

-
-
- ) : data.length === 0 ? ( -
-

🙌

-

- You two didn't talk yet, let's send first message -

-
- ) : ( - - {data.map((message) => renderItem(message))} - - )} +
+
+
+
+ {status === 'loading' ? ( +
+
+ +
Loading messages...
+
-
- -
-
+ ) : ( + data.map((item) => renderItem(item)) + )}
-
- +
+
); diff --git a/src/index.css b/src/index.css index 6d57156e..ec134231 100644 --- a/src/index.css +++ b/src/index.css @@ -50,15 +50,15 @@ input::-ms-clear { } .markdown { - @apply prose prose-white max-w-none select-text text-white prose-headings:mb-1 prose-headings:mt-3 prose-p:mb-0 prose-p:mt-0 prose-p:last:mb-0 prose-a:font-normal hover:prose-a:text-fuchsia-500 prose-blockquote:mb-1 prose-blockquote:mt-1 prose-blockquote:border-l-[2px] prose-blockquote:border-fuchsia-500 prose-blockquote:pl-2 prose-pre:whitespace-pre-wrap prose-pre:break-words prose-pre:break-all prose-pre:bg-white/10 prose-ol:m-0 prose-ol:mb-1 prose-ul:mb-1 prose-ul:mt-1 prose-img:mb-2 prose-img:mt-3 prose-hr:mx-0 prose-hr:my-2; + @apply prose prose-white max-w-none select-text whitespace-pre-line text-white prose-headings:mb-1 prose-headings:mt-3 prose-p:mb-0 prose-p:mt-0 prose-p:last:mb-0 prose-a:font-normal hover:prose-a:text-fuchsia-500 prose-blockquote:mb-1 prose-blockquote:mt-1 prose-blockquote:border-l-[2px] prose-blockquote:border-fuchsia-500 prose-blockquote:pl-2 prose-pre:whitespace-pre-wrap prose-pre:break-words prose-pre:break-all prose-pre:bg-white/10 prose-ol:m-0 prose-ol:mb-1 prose-ul:mb-1 prose-ul:mt-1 prose-img:mb-2 prose-img:mt-3 prose-hr:mx-0 prose-hr:my-2; } .markdown-article { - @apply prose prose-white max-w-none select-text text-white/80 prose-headings:mb-1 prose-headings:mt-3 prose-headings:text-white prose-p:mb-2 prose-p:mt-0 prose-p:break-words prose-p:[word-break:break-word] prose-a:break-words prose-a:break-all prose-a:font-normal hover:prose-a:text-fuchsia-500 prose-blockquote:mb-1 prose-blockquote:mt-1 prose-blockquote:border-l-[2px] prose-blockquote:border-fuchsia-500 prose-blockquote:pl-2 prose-pre:whitespace-pre-wrap prose-pre:break-words prose-pre:break-all prose-pre:bg-white/10 prose-ol:m-0 prose-ol:mb-1 prose-ul:mb-1 prose-ul:mt-1 prose-img:mb-2 prose-img:mt-3 prose-hr:mx-0 prose-hr:my-2; + @apply prose prose-white max-w-none select-text whitespace-pre-line text-white/80 prose-headings:mb-1 prose-headings:mt-3 prose-headings:text-white prose-p:mb-2 prose-p:mt-0 prose-p:break-words prose-p:[word-break:break-word] prose-a:break-words prose-a:break-all prose-a:font-normal hover:prose-a:text-fuchsia-500 prose-blockquote:mb-1 prose-blockquote:mt-1 prose-blockquote:border-l-[2px] prose-blockquote:border-fuchsia-500 prose-blockquote:pl-2 prose-pre:whitespace-pre-wrap prose-pre:break-words prose-pre:break-all prose-pre:bg-white/10 prose-ol:m-0 prose-ol:mb-1 prose-ul:mb-1 prose-ul:mt-1 prose-img:mb-2 prose-img:mt-3 prose-hr:mx-0 prose-hr:my-2; } .markdown-simple { - @apply prose prose-white max-w-none select-text hyphens-auto text-white/70 prose-headings:mb-1 prose-headings:mt-3 prose-p:mb-0 prose-p:mt-0 prose-p:break-words prose-p:[word-break:break-word] prose-p:last:mb-0 prose-a:break-words prose-a:break-all prose-a:font-normal hover:prose-a:text-fuchsia-500 prose-blockquote:mb-1 prose-blockquote:mt-1 prose-blockquote:border-l-[2px] prose-blockquote:border-fuchsia-500 prose-blockquote:pl-2 prose-pre:whitespace-pre-wrap prose-pre:break-words prose-pre:break-all prose-pre:bg-white/10 prose-ol:m-0 prose-ol:mb-1 prose-ul:mb-1 prose-ul:mt-1 prose-img:mb-2 prose-img:mt-3 prose-hr:mx-0 prose-hr:my-2; + @apply prose prose-white max-w-none select-text hyphens-auto whitespace-pre-line text-white/70 prose-headings:mb-1 prose-headings:mt-3 prose-p:mb-0 prose-p:mt-0 prose-p:break-words prose-p:[word-break:break-word] prose-p:last:mb-0 prose-a:break-words prose-a:break-all prose-a:font-normal hover:prose-a:text-fuchsia-500 prose-blockquote:mb-1 prose-blockquote:mt-1 prose-blockquote:border-l-[2px] prose-blockquote:border-fuchsia-500 prose-blockquote:pl-2 prose-pre:whitespace-pre-wrap prose-pre:break-words prose-pre:break-all prose-pre:bg-white/10 prose-ol:m-0 prose-ol:mb-1 prose-ul:mb-1 prose-ul:mt-1 prose-img:mb-2 prose-img:mt-3 prose-hr:mx-0 prose-hr:my-2; } .ProseMirror p.is-empty::before { diff --git a/src/shared/accounts/active.tsx b/src/shared/accounts/active.tsx index b84c9b37..d640cc41 100644 --- a/src/shared/accounts/active.tsx +++ b/src/shared/accounts/active.tsx @@ -52,7 +52,7 @@ export function ActiveAccount() { if (status === 'loading') { return ( -
+
@@ -60,7 +60,7 @@ export function ActiveAccount() { } return ( -
+
-
+
- +
diff --git a/src/shared/icons/chats.tsx b/src/shared/icons/chats.tsx new file mode 100644 index 00000000..dc3fd919 --- /dev/null +++ b/src/shared/icons/chats.tsx @@ -0,0 +1,21 @@ +import { SVGProps } from 'react'; + +export function ChatsIcon(props: JSX.IntrinsicAttributes & SVGProps) { + return ( + + + + ); +} diff --git a/src/shared/icons/index.ts b/src/shared/icons/index.ts index dbc104cb..6bb149d5 100644 --- a/src/shared/icons/index.ts +++ b/src/shared/icons/index.ts @@ -71,3 +71,4 @@ export * from './relay'; export * from './explore'; export * from './explore2'; export * from './home'; +export * from './chats'; diff --git a/src/shared/logout.tsx b/src/shared/logout.tsx index 0ec00eaf..4effbf47 100644 --- a/src/shared/logout.tsx +++ b/src/shared/logout.tsx @@ -27,9 +27,9 @@ export function Logout() { diff --git a/src/shared/navigation.tsx b/src/shared/navigation.tsx index 65a128df..4c3e1922 100644 --- a/src/shared/navigation.tsx +++ b/src/shared/navigation.tsx @@ -9,6 +9,7 @@ import { Frame } from '@shared/frame'; import { ArrowLeftIcon, ArrowRightIcon, + ChatsIcon, ExploreIcon, HomeIcon, RelayIcon, @@ -52,15 +53,15 @@ export function Navigation() {
-
+
twMerge( - 'flex h-10 items-center gap-2.5 rounded-r-lg border-l-2 pl-4 pr-3 font-medium', + 'flex h-10 items-center gap-2.5 rounded-r-lg border-l-2 px-3 font-medium', isActive ? 'border-fuchsia-500 bg-white/5 text-white' : 'border-transparent text-white/70' @@ -72,12 +73,29 @@ export function Navigation() { Home + + twMerge( + 'flex h-10 items-center gap-2.5 rounded-r-lg border-l-2 px-3 font-medium', + isActive + ? 'border-fuchsia-500 bg-white/5 text-white' + : 'border-transparent text-white/70' + ) + } + > + + + + Chats + twMerge( - 'flex h-10 items-center gap-2.5 rounded-r-lg border-l-2 pl-4 pr-3 font-medium', + 'flex h-10 items-center gap-2.5 rounded-r-lg border-l-2 px-3 font-medium', isActive ? 'border-fuchsia-500 bg-white/5 text-white' : 'border-transparent text-white/70' @@ -94,7 +112,7 @@ export function Navigation() { preventScrollReset={true} className={({ isActive }) => twMerge( - 'flex h-10 items-center gap-2.5 rounded-r-lg border-l-2 pl-4 pr-3 font-medium', + 'flex h-10 items-center gap-2.5 rounded-r-lg border-l-2 px-3 font-medium', isActive ? 'border-fuchsia-500 bg-white/5 text-white' : 'border-transparent text-white/70' diff --git a/src/shared/notes/kinds/text.tsx b/src/shared/notes/kinds/text.tsx index 82f34e7b..6f2a3dab 100644 --- a/src/shared/notes/kinds/text.tsx +++ b/src/shared/notes/kinds/text.tsx @@ -23,7 +23,7 @@ export function TextNote(props: { content?: string }) { diff --git a/src/utils/hooks/useNostr.ts b/src/utils/hooks/useNostr.ts index 9aae02ac..9d24d449 100644 --- a/src/utils/hooks/useNostr.ts +++ b/src/utils/hooks/useNostr.ts @@ -168,23 +168,6 @@ export function useNostr() { } }; - const fetchNIP04Chats = async () => { - const events = await fetcher.fetchAllEvents( - relayUrls, - { - kinds: [NDKKind.EncryptedDirectMessage], - '#p': [db.account.pubkey], - }, - { since: 0 } - ); - - const senders = events.map((e) => e.pubkey); - const follows = new Set(senders.filter((el) => db.account.follows.includes(el))); - const unknowns = new Set(senders.filter((el) => !db.account.follows.includes(el))); - - return { follows: [...follows], unknowns: [...unknowns] }; - }; - const fetchNIP04Messages = async (sender: string) => { let senderMessages: NostrEventExt[] = []; @@ -258,6 +241,32 @@ export function useNostr() { return events; }; + const getAllNIP04Chats = async () => { + const events = await fetcher.fetchAllEvents( + relayUrls, + { + kinds: [NDKKind.EncryptedDirectMessage], + '#p': [db.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; + }; + const getAllEventsSinceLastLogin = async (customSince?: number) => { try { let since: number; @@ -465,9 +474,9 @@ export function useNostr() { fetchUserData, addContact, removeContact, + getAllNIP04Chats, getAllEventsSinceLastLogin, fetchActivities, - fetchNIP04Chats, fetchNIP04Messages, fetchAllReplies, publish,