diff --git a/src/AppRouter.tsx b/src/AppRouter.tsx index 976cccce..72631b91 100644 --- a/src/AppRouter.tsx +++ b/src/AppRouter.tsx @@ -39,6 +39,7 @@ const CSAEPolicyPage = lazy(() => import("./pages/CSAEPolicyPage").then(m => ({ const ExternalContentPage = lazy(() => import("./pages/ExternalContentPage").then(m => ({ default: m.ExternalContentPage }))); const GeotagPage = lazy(() => import("./pages/GeotagPage").then(m => ({ default: m.GeotagPage }))); const HashtagPage = lazy(() => import("./pages/HashtagPage").then(m => ({ default: m.HashtagPage }))); +const MessagesPage = lazy(() => import("./pages/MessagesPage").then(m => ({ default: m.MessagesPage }))); const MyDashboardPage = lazy(() => import("./pages/MyDashboardPage").then(m => ({ default: m.MyDashboardPage }))); const AboutPage = lazy(() => import("./pages/AboutPage").then(m => ({ default: m.AboutPage }))); const DonorGuidePage = lazy(() => import("./pages/DonorGuidePage").then(m => ({ default: m.DonorGuidePage }))); @@ -152,6 +153,7 @@ export function AppRouter() { }> } /> } /> + } /> } /> } /> } /> diff --git a/src/components/auth/AccountSwitcher.tsx b/src/components/auth/AccountSwitcher.tsx index f8e9cd7b..38281677 100644 --- a/src/components/auth/AccountSwitcher.tsx +++ b/src/components/auth/AccountSwitcher.tsx @@ -7,7 +7,7 @@ import { useState } from 'react'; import { Link } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; -import { Activity, Bell, ChevronDown, LayoutDashboard, LogOut, Settings, UserIcon, UserPlus, Wallet } from 'lucide-react'; +import { Activity, Bell, ChevronDown, LayoutDashboard, LogOut, MessageSquare, Settings, UserIcon, UserPlus, Wallet } from 'lucide-react'; import { nip19 } from 'nostr-tools'; import { DropdownMenu, @@ -118,6 +118,12 @@ export function AccountSwitcher({ onAddAccountClick }: AccountSwitcherProps) { {t('nav.wallet')} + + + + {t('nav.messages')} + + diff --git a/src/hooks/useDirectMessages.ts b/src/hooks/useDirectMessages.ts new file mode 100644 index 00000000..ebf4dca0 --- /dev/null +++ b/src/hooks/useDirectMessages.ts @@ -0,0 +1,152 @@ +import { useNostr } from '@nostrify/react'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import type { NostrEvent } from '@nostrify/nostrify'; + +import { useCurrentUser } from './useCurrentUser'; +import { useNostrPublish } from './useNostrPublish'; + +/** NIP-04 encrypted direct message kind. */ +export const DM_KIND = 4; + +/** A decrypted (or undecryptable) message in a conversation. */ +export interface DirectMessage { + id: string; + /** Author of the event (the sender). */ + pubkey: string; + /** The counterparty in this conversation (always the *other* person). */ + peer: string; + /** Whether the logged-in user authored this message. */ + outgoing: boolean; + createdAt: number; + /** Decrypted plaintext, or `null` if decryption failed. */ + content: string | null; + event: NostrEvent; +} + +/** A single conversation: the peer pubkey plus its decrypted messages. */ +export interface Conversation { + peer: string; + messages: DirectMessage[]; + latest: DirectMessage; +} + +/** Extract the first `p` tag value (the recipient) from a kind-4 event. */ +function recipientOf(event: NostrEvent): string | undefined { + return event.tags.find(([name]) => name === 'p')?.[1]; +} + +/** True if the signer can perform NIP-04 encryption. */ +export function useHasDmSupport(): boolean { + const { user } = useCurrentUser(); + return !!user?.signer.nip04; +} + +/** + * Loads all NIP-04 (kind-4) direct messages for the logged-in user, decrypts + * them with the signer's `nip04` methods, and groups them into conversations + * sorted by most-recent activity. + * + * Messages are read from the app's configured relays (via `useNostr`), so this + * automatically honors the user's relay settings. NIP-04 leaks metadata and is + * deprecated in favor of NIP-44/NIP-17; this exists for interop with clients + * that still send kind-4 DMs. + */ +export function useDirectMessages() { + const { nostr } = useNostr(); + const { user } = useCurrentUser(); + const self = user?.pubkey; + + return useQuery({ + queryKey: ['direct-messages', self], + enabled: !!self && !!user?.signer.nip04, + queryFn: async ({ signal }) => { + if (!self || !user?.signer.nip04) return []; + const nip04 = user.signer.nip04; + + // Both directions of every conversation we're part of: messages we sent + // (authors = self) and messages addressed to us (#p = self). + const events = await nostr.query( + [ + { kinds: [DM_KIND], authors: [self] }, + { kinds: [DM_KIND], '#p': [self] }, + ], + { signal }, + ); + + // Dedupe by id (a self-sent message can match both filters). + const byId = new Map(); + for (const event of events) byId.set(event.id, event); + + // Group by counterparty pubkey. + const byPeer = new Map(); + for (const event of byId.values()) { + const outgoing = event.pubkey === self; + const peer = outgoing ? recipientOf(event) : event.pubkey; + if (!peer) continue; + const list = byPeer.get(peer) ?? []; + list.push(event); + byPeer.set(peer, list); + } + + const conversations: Conversation[] = []; + for (const [peer, peerEvents] of byPeer) { + peerEvents.sort((a, b) => a.created_at - b.created_at); + + const messages: DirectMessage[] = []; + for (const event of peerEvents) { + const outgoing = event.pubkey === self; + let content: string | null = null; + try { + // NIP-04 decrypt takes the *counterparty* pubkey. + content = await nip04.decrypt(peer, event.content); + } catch { + content = null; + } + messages.push({ + id: event.id, + pubkey: event.pubkey, + peer, + outgoing, + createdAt: event.created_at, + content, + event, + }); + } + + const latest = messages[messages.length - 1]; + if (!latest) continue; + conversations.push({ peer, messages, latest }); + } + + // Most-recently-active conversations first. + conversations.sort((a, b) => b.latest.createdAt - a.latest.createdAt); + return conversations; + }, + }); +} + +/** + * Send a NIP-04 encrypted direct message to a peer. Encrypts the plaintext with + * the signer's `nip04.encrypt`, then publishes a kind-4 event tagging the + * recipient. Invalidates the DM query so the new message shows up. + */ +export function useSendDirectMessage() { + const { user } = useCurrentUser(); + const { mutateAsync: publish } = useNostrPublish(); + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async ({ peer, text }: { peer: string; text: string }) => { + const trimmed = text.trim(); + if (!trimmed) throw new Error('Cannot send an empty message'); + if (!user?.signer.nip04) { + throw new Error('NIP-04 encryption is not supported by your signer'); + } + const content = await user.signer.nip04.encrypt(peer, trimmed); + return publish({ kind: DM_KIND, content, tags: [['p', peer]] }); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['direct-messages', user?.pubkey] }); + }, + }); +} diff --git a/src/locales/ar.json b/src/locales/ar.json index 9f9dc9c9..6356624b 100644 --- a/src/locales/ar.json +++ b/src/locales/ar.json @@ -81,6 +81,7 @@ "myDashboard": "لوحتي", "wallet": "المحفظة", "notifications": "الإشعارات", + "messages": "الرسائل", "profile": "الملف الشخصي", "settings": "الإعدادات", "about": "حول", @@ -91,6 +92,21 @@ "sourceCode": "الكود المصدري", "getApp": "احصل على التطبيق" }, + "messages": { + "title": "الرسائل", + "subtitle": "رسائلك المباشرة الخاصة والمشفرة.", + "conversations": "المحادثات", + "empty": "لا توجد رسائل بعد. ستظهر هنا المحادثات التي تجريها.", + "selectPrompt": "اختر محادثة لقراءة رسائلك.", + "composePlaceholder": "اكتب رسالة…", + "send": "إرسال", + "sendFailed": "فشل إرسال الرسالة", + "youPrefix": "أنت:", + "encryptedSent": "رسالة مشفرة", + "encryptedReceived": "رسالة مشفرة", + "decryptFailed": "تعذّر فك تشفير هذه الرسالة.", + "unsupported": "طريقة تسجيل الدخول الحالية لا تدعم الرسائل المباشرة المشفرة." + }, "auth": { "join": "انضمام", "login": "تسجيل الدخول", diff --git a/src/locales/en.json b/src/locales/en.json index 44c24762..c1d1de80 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -81,6 +81,7 @@ "myDashboard": "My Dashboard", "wallet": "Wallet", "notifications": "Notifications", + "messages": "Messages", "profile": "Profile", "settings": "Settings", "about": "About", @@ -92,6 +93,21 @@ "sourceCode": "Source code", "getApp": "Get the app" }, + "messages": { + "title": "Messages", + "subtitle": "Your private encrypted direct messages.", + "conversations": "Conversations", + "empty": "No messages yet. Conversations you have will appear here.", + "selectPrompt": "Select a conversation to read your messages.", + "composePlaceholder": "Write a message…", + "send": "Send", + "sendFailed": "Failed to send message", + "youPrefix": "You:", + "encryptedSent": "Encrypted message", + "encryptedReceived": "Encrypted message", + "decryptFailed": "Unable to decrypt this message.", + "unsupported": "Your current login method does not support encrypted direct messages." + }, "auth": { "join": "Join", "login": "Log in", diff --git a/src/locales/es.json b/src/locales/es.json index 00d825a4..7728fba3 100644 --- a/src/locales/es.json +++ b/src/locales/es.json @@ -81,6 +81,7 @@ "myDashboard": "Mi panel", "wallet": "Cartera", "notifications": "Notificaciones", + "messages": "Mensajes", "profile": "Perfil", "settings": "Ajustes", "about": "Acerca de", @@ -91,6 +92,21 @@ "sourceCode": "Código fuente", "getApp": "Descargar la app" }, + "messages": { + "title": "Mensajes", + "subtitle": "Tus mensajes directos privados y cifrados.", + "conversations": "Conversaciones", + "empty": "Aún no hay mensajes. Las conversaciones que tengas aparecerán aquí.", + "selectPrompt": "Selecciona una conversación para leer tus mensajes.", + "composePlaceholder": "Escribe un mensaje…", + "send": "Enviar", + "sendFailed": "No se pudo enviar el mensaje", + "youPrefix": "Tú:", + "encryptedSent": "Mensaje cifrado", + "encryptedReceived": "Mensaje cifrado", + "decryptFailed": "No se pudo descifrar este mensaje.", + "unsupported": "Tu método de inicio de sesión actual no admite mensajes directos cifrados." + }, "auth": { "join": "Unirse", "login": "Iniciar sesión", diff --git a/src/locales/fa.json b/src/locales/fa.json index 17ef9276..5dfb90c1 100644 --- a/src/locales/fa.json +++ b/src/locales/fa.json @@ -81,6 +81,7 @@ "myDashboard": "داشبورد من", "wallet": "کیف پول", "notifications": "اعلان‌ها", + "messages": "پیام‌ها", "profile": "نمایه", "settings": "تنظیمات", "about": "درباره", @@ -91,6 +92,21 @@ "sourceCode": "کد منبع", "getApp": "دریافت برنامه" }, + "messages": { + "title": "پیام‌ها", + "subtitle": "پیام‌های مستقیم خصوصی و رمزگذاری‌شده شما.", + "conversations": "گفتگوها", + "empty": "هنوز پیامی وجود ندارد. گفتگوهایی که دارید اینجا نمایش داده می‌شوند.", + "selectPrompt": "یک گفتگو را برای خواندن پیام‌هایتان انتخاب کنید.", + "composePlaceholder": "پیامی بنویسید…", + "send": "ارسال", + "sendFailed": "ارسال پیام ناموفق بود", + "youPrefix": "شما:", + "encryptedSent": "پیام رمزگذاری‌شده", + "encryptedReceived": "پیام رمزگذاری‌شده", + "decryptFailed": "رمزگشایی این پیام ممکن نیست.", + "unsupported": "روش ورود فعلی شما از پیام‌های مستقیم رمزگذاری‌شده پشتیبانی نمی‌کند." + }, "auth": { "join": "پیوستن", "login": "ورود", diff --git a/src/locales/fr.json b/src/locales/fr.json index e2b0fbc5..0871a32e 100644 --- a/src/locales/fr.json +++ b/src/locales/fr.json @@ -81,6 +81,7 @@ "myDashboard": "Mon tableau de bord", "wallet": "Portefeuille", "notifications": "Notifications", + "messages": "Messages", "profile": "Profil", "settings": "Paramètres", "about": "À propos", @@ -91,6 +92,21 @@ "sourceCode": "Code source", "getApp": "Télécharger l'application" }, + "messages": { + "title": "Messages", + "subtitle": "Vos messages directs privés et chiffrés.", + "conversations": "Conversations", + "empty": "Aucun message pour le moment. Les conversations que vous aurez apparaîtront ici.", + "selectPrompt": "Sélectionnez une conversation pour lire vos messages.", + "composePlaceholder": "Écrire un message…", + "send": "Envoyer", + "sendFailed": "Échec de l'envoi du message", + "youPrefix": "Vous :", + "encryptedSent": "Message chiffré", + "encryptedReceived": "Message chiffré", + "decryptFailed": "Impossible de déchiffrer ce message.", + "unsupported": "Votre méthode de connexion actuelle ne prend pas en charge les messages directs chiffrés." + }, "auth": { "join": "Rejoindre", "login": "Se connecter", diff --git a/src/locales/hi.json b/src/locales/hi.json index 49b602ad..247d385b 100644 --- a/src/locales/hi.json +++ b/src/locales/hi.json @@ -81,6 +81,7 @@ "myDashboard": "मेरा डैशबोर्ड", "wallet": "वॉलेट", "notifications": "नोटिफिकेशन", + "messages": "संदेश", "profile": "प्रोफ़ाइल", "settings": "सेटिंग्स", "about": "बारे में", @@ -91,6 +92,21 @@ "sourceCode": "सोर्स कोड", "getApp": "ऐप पाएं" }, + "messages": { + "title": "संदेश", + "subtitle": "आपके निजी एन्क्रिप्टेड डायरेक्ट संदेश।", + "conversations": "बातचीत", + "empty": "अभी तक कोई संदेश नहीं। आपकी बातचीत यहाँ दिखाई देगी।", + "selectPrompt": "अपने संदेश पढ़ने के लिए एक बातचीत चुनें।", + "composePlaceholder": "एक संदेश लिखें…", + "send": "भेजें", + "sendFailed": "संदेश भेजने में विफल", + "youPrefix": "आप:", + "encryptedSent": "एन्क्रिप्टेड संदेश", + "encryptedReceived": "एन्क्रिप्टेड संदेश", + "decryptFailed": "इस संदेश को डिक्रिप्ट करने में असमर्थ।", + "unsupported": "आपकी वर्तमान लॉगिन विधि एन्क्रिप्टेड डायरेक्ट संदेशों का समर्थन नहीं करती।" + }, "auth": { "join": "जुड़ें", "login": "लॉग इन करें", diff --git a/src/locales/id.json b/src/locales/id.json index a5d1700a..02d4071d 100644 --- a/src/locales/id.json +++ b/src/locales/id.json @@ -81,6 +81,7 @@ "myDashboard": "Dasbor Saya", "wallet": "Dompet", "notifications": "Notifikasi", + "messages": "Pesan", "profile": "Profil", "settings": "Pengaturan", "about": "Tentang", @@ -91,6 +92,21 @@ "sourceCode": "Kode sumber", "getApp": "Dapatkan aplikasi" }, + "messages": { + "title": "Pesan", + "subtitle": "Pesan langsung pribadi Anda yang terenkripsi.", + "conversations": "Percakapan", + "empty": "Belum ada pesan. Percakapan yang Anda miliki akan muncul di sini.", + "selectPrompt": "Pilih percakapan untuk membaca pesan Anda.", + "composePlaceholder": "Tulis pesan…", + "send": "Kirim", + "sendFailed": "Gagal mengirim pesan", + "youPrefix": "Anda:", + "encryptedSent": "Pesan terenkripsi", + "encryptedReceived": "Pesan terenkripsi", + "decryptFailed": "Tidak dapat mendekripsi pesan ini.", + "unsupported": "Metode masuk Anda saat ini tidak mendukung pesan langsung terenkripsi." + }, "auth": { "join": "Gabung", "login": "Masuk", diff --git a/src/locales/km.json b/src/locales/km.json index b755f624..2e5008ca 100644 --- a/src/locales/km.json +++ b/src/locales/km.json @@ -81,6 +81,7 @@ "myDashboard": "ផ្ទាំងគ្រប់គ្រងរបស់ខ្ញុំ", "wallet": "កាបូប", "notifications": "ការជូនដំណឹង", + "messages": "សារ", "profile": "ប្រវត្តិរូប", "settings": "ការកំណត់", "about": "អំពី", @@ -91,6 +92,21 @@ "sourceCode": "កូដប្រភព", "getApp": "ទាញយកកម្មវិធី" }, + "messages": { + "title": "សារ", + "subtitle": "សារផ្ទាល់ខ្លួនដែលបានអ៊ីនគ្រីបរបស់អ្នក។", + "conversations": "ការសន្ទនា", + "empty": "មិនទាន់មានសារនៅឡើយទេ។ ការសន្ទនាដែលអ្នកមាននឹងបង្ហាញនៅទីនេះ។", + "selectPrompt": "ជ្រើសរើសការសន្ទនាមួយដើម្បីអានសាររបស់អ្នក។", + "composePlaceholder": "សរសេរសារ…", + "send": "ផ្ញើ", + "sendFailed": "ការផ្ញើសារបានបរាជ័យ", + "youPrefix": "អ្នក៖", + "encryptedSent": "សារដែលបានអ៊ីនគ្រីប", + "encryptedReceived": "សារដែលបានអ៊ីនគ្រីប", + "decryptFailed": "មិនអាចឌិគ្រីបសារនេះបានទេ។", + "unsupported": "វិធីសាស្ត្រចូលគណនីបច្ចុប្បន្នរបស់អ្នកមិនគាំទ្រសារផ្ទាល់ខ្លួនដែលបានអ៊ីនគ្រីបទេ។" + }, "auth": { "join": "ចូលរួម", "login": "ចូលគណនី", diff --git a/src/locales/ps.json b/src/locales/ps.json index 6bb3a67b..03a143c9 100644 --- a/src/locales/ps.json +++ b/src/locales/ps.json @@ -81,6 +81,7 @@ "myDashboard": "زما ډیشبورډ", "wallet": "بټوه", "notifications": "خبرتیاوې", + "messages": "پیغامونه", "profile": "پروفایل", "settings": "تنظیمات", "about": "په اړه", @@ -91,6 +92,21 @@ "sourceCode": "د سرچینې کوډ", "getApp": "اپ ترلاسه کړئ" }, + "messages": { + "title": "پیغامونه", + "subtitle": "ستاسو شخصي کوډ شوي مستقیم پیغامونه.", + "conversations": "خبرې اترې", + "empty": "تر اوسه هیڅ پیغام نشته. هغه خبرې اترې چې تاسو یې لرئ دلته به ښکاره شي.", + "selectPrompt": "د خپلو پیغامونو لوستلو لپاره یوه خبره اتره وټاکئ.", + "composePlaceholder": "یو پیغام ولیکئ…", + "send": "لیږل", + "sendFailed": "د پیغام لیږل ناکام شول", + "youPrefix": "تاسو:", + "encryptedSent": "کوډ شوی پیغام", + "encryptedReceived": "کوډ شوی پیغام", + "decryptFailed": "د دې پیغام کوډ خلاصول نشي کیدی.", + "unsupported": "ستاسو اوسنۍ د ننوتلو طریقه کوډ شوي مستقیم پیغامونه نه ملاتړ کوي." + }, "auth": { "join": "ګډون", "login": "ننوتل", diff --git a/src/locales/pt.json b/src/locales/pt.json index 631449c8..ea619415 100644 --- a/src/locales/pt.json +++ b/src/locales/pt.json @@ -81,6 +81,7 @@ "myDashboard": "Meu painel", "wallet": "Carteira", "notifications": "Notificações", + "messages": "Mensagens", "profile": "Perfil", "settings": "Configurações", "about": "Sobre", @@ -91,6 +92,21 @@ "sourceCode": "Código-fonte", "getApp": "Baixar o app" }, + "messages": { + "title": "Mensagens", + "subtitle": "Suas mensagens diretas privadas e criptografadas.", + "conversations": "Conversas", + "empty": "Ainda não há mensagens. As conversas que você tiver aparecerão aqui.", + "selectPrompt": "Selecione uma conversa para ler suas mensagens.", + "composePlaceholder": "Escreva uma mensagem…", + "send": "Enviar", + "sendFailed": "Falha ao enviar a mensagem", + "youPrefix": "Você:", + "encryptedSent": "Mensagem criptografada", + "encryptedReceived": "Mensagem criptografada", + "decryptFailed": "Não foi possível descriptografar esta mensagem.", + "unsupported": "Seu método de login atual não oferece suporte a mensagens diretas criptografadas." + }, "auth": { "join": "Entrar", "login": "Entrar", diff --git a/src/locales/ru.json b/src/locales/ru.json index 92d3decc..1ad18a23 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -81,6 +81,7 @@ "myDashboard": "Моя панель", "wallet": "Кошелёк", "notifications": "Уведомления", + "messages": "Сообщения", "profile": "Профиль", "settings": "Настройки", "about": "О приложении", @@ -91,6 +92,21 @@ "sourceCode": "Исходный код", "getApp": "Скачать приложение" }, + "messages": { + "title": "Сообщения", + "subtitle": "Ваши личные зашифрованные прямые сообщения.", + "conversations": "Беседы", + "empty": "Сообщений пока нет. Здесь появятся ваши беседы.", + "selectPrompt": "Выберите беседу, чтобы прочитать сообщения.", + "composePlaceholder": "Напишите сообщение…", + "send": "Отправить", + "sendFailed": "Не удалось отправить сообщение", + "youPrefix": "Вы:", + "encryptedSent": "Зашифрованное сообщение", + "encryptedReceived": "Зашифрованное сообщение", + "decryptFailed": "Не удалось расшифровать это сообщение.", + "unsupported": "Ваш текущий способ входа не поддерживает зашифрованные прямые сообщения." + }, "auth": { "join": "Присоединиться", "login": "Войти", diff --git a/src/locales/sn.json b/src/locales/sn.json index b8c5108b..67386127 100644 --- a/src/locales/sn.json +++ b/src/locales/sn.json @@ -81,6 +81,7 @@ "myDashboard": "Dashboard Yangu", "wallet": "Chikwama", "notifications": "Zviziviso", + "messages": "Mameseji", "profile": "Profile", "settings": "Marongero", "about": "Nezve", @@ -91,6 +92,21 @@ "sourceCode": "Kodhi yetsime", "getApp": "Tora app" }, + "messages": { + "title": "Mameseji", + "subtitle": "Mameseji ako akavanzika akakodheswa.", + "conversations": "Nhaurirano", + "empty": "Hapana mameseji parizvino. Nhaurirano dzaunazvo dzichaonekwa pano.", + "selectPrompt": "Sarudza nhaurirano kuti uverenge mameseji ako.", + "composePlaceholder": "Nyora meseji…", + "send": "Tumira", + "sendFailed": "Kutumira meseji kwakundikana", + "youPrefix": "Iwe:", + "encryptedSent": "Meseji yakakodheswa", + "encryptedReceived": "Meseji yakakodheswa", + "decryptFailed": "Hazvigoneki kudekodha meseji iyi.", + "unsupported": "Nzira yako yazvino yekupinda haitsigire mameseji akavanzika akakodheswa." + }, "auth": { "join": "Joinha", "login": "Pinda", diff --git a/src/locales/sw.json b/src/locales/sw.json index b1fd4cd5..c8417011 100644 --- a/src/locales/sw.json +++ b/src/locales/sw.json @@ -80,6 +80,7 @@ "dashboard": "Dashibodi", "wallet": "Pochi", "notifications": "Arifa", + "messages": "Ujumbe", "profile": "Wasifu", "settings": "Mipangilio", "about": "Kuhusu", @@ -91,6 +92,21 @@ "getApp": "Pata programu", "myDashboard": "Dashibodi Yangu" }, + "messages": { + "title": "Ujumbe", + "subtitle": "Ujumbe wako wa moja kwa moja wa faragha uliosimbwa.", + "conversations": "Mazungumzo", + "empty": "Hakuna ujumbe bado. Mazungumzo uliyo nayo yataonekana hapa.", + "selectPrompt": "Chagua mazungumzo ili kusoma ujumbe wako.", + "composePlaceholder": "Andika ujumbe…", + "send": "Tuma", + "sendFailed": "Imeshindwa kutuma ujumbe", + "youPrefix": "Wewe:", + "encryptedSent": "Ujumbe uliosimbwa", + "encryptedReceived": "Ujumbe uliosimbwa", + "decryptFailed": "Imeshindwa kusimbua ujumbe huu.", + "unsupported": "Njia yako ya sasa ya kuingia hairuhusu ujumbe wa moja kwa moja uliosimbwa." + }, "auth": { "join": "Jiunge", "login": "Ingia", diff --git a/src/locales/tr.json b/src/locales/tr.json index c24de1e3..637c2e18 100644 --- a/src/locales/tr.json +++ b/src/locales/tr.json @@ -81,6 +81,7 @@ "myDashboard": "Panelim", "wallet": "Cüzdan", "notifications": "Bildirimler", + "messages": "Mesajlar", "profile": "Profil", "settings": "Ayarlar", "about": "Hakkında", @@ -91,6 +92,21 @@ "sourceCode": "Kaynak kod", "getApp": "Uygulamayı edinin" }, + "messages": { + "title": "Mesajlar", + "subtitle": "Özel şifreli doğrudan mesajlarınız.", + "conversations": "Sohbetler", + "empty": "Henüz mesaj yok. Sahip olduğunuz sohbetler burada görünecek.", + "selectPrompt": "Mesajlarınızı okumak için bir sohbet seçin.", + "composePlaceholder": "Bir mesaj yazın…", + "send": "Gönder", + "sendFailed": "Mesaj gönderilemedi", + "youPrefix": "Siz:", + "encryptedSent": "Şifreli mesaj", + "encryptedReceived": "Şifreli mesaj", + "decryptFailed": "Bu mesajın şifresi çözülemiyor.", + "unsupported": "Mevcut giriş yönteminiz şifreli doğrudan mesajları desteklemiyor." + }, "auth": { "join": "Katıl", "login": "Giriş yap", diff --git a/src/locales/zh-Hant.json b/src/locales/zh-Hant.json index a9599259..2ef918a6 100644 --- a/src/locales/zh-Hant.json +++ b/src/locales/zh-Hant.json @@ -81,6 +81,7 @@ "myDashboard": "我的儀表板", "wallet": "錢包", "notifications": "通知", + "messages": "私訊", "profile": "個人資料", "settings": "設定", "about": "關於", @@ -91,6 +92,21 @@ "sourceCode": "原始碼", "getApp": "取得應用程式" }, + "messages": { + "title": "私訊", + "subtitle": "您的私密加密私訊。", + "conversations": "對話", + "empty": "暫無訊息。您的對話將顯示在此處。", + "selectPrompt": "選擇一個對話以閱讀您的訊息。", + "composePlaceholder": "撰寫訊息…", + "send": "傳送", + "sendFailed": "訊息傳送失敗", + "youPrefix": "您:", + "encryptedSent": "加密訊息", + "encryptedReceived": "加密訊息", + "decryptFailed": "無法解密此訊息。", + "unsupported": "您目前的登入方式不支援加密私訊。" + }, "auth": { "join": "加入", "login": "登入", diff --git a/src/locales/zh.json b/src/locales/zh.json index 6ea712b3..11c070a7 100644 --- a/src/locales/zh.json +++ b/src/locales/zh.json @@ -81,6 +81,7 @@ "myDashboard": "我的仪表板", "wallet": "钱包", "notifications": "通知", + "messages": "私信", "profile": "个人资料", "settings": "设置", "about": "关于", @@ -91,6 +92,21 @@ "sourceCode": "源代码", "getApp": "获取应用" }, + "messages": { + "title": "私信", + "subtitle": "您的私密加密私信。", + "conversations": "对话", + "empty": "暂无消息。您的对话将显示在此处。", + "selectPrompt": "选择一个对话以阅读您的消息。", + "composePlaceholder": "写一条消息…", + "send": "发送", + "sendFailed": "消息发送失败", + "youPrefix": "您:", + "encryptedSent": "加密消息", + "encryptedReceived": "加密消息", + "decryptFailed": "无法解密此消息。", + "unsupported": "您当前的登录方式不支持加密私信。" + }, "auth": { "join": "加入", "login": "登录", diff --git a/src/pages/MessagesPage.tsx b/src/pages/MessagesPage.tsx new file mode 100644 index 00000000..b7f2d20d --- /dev/null +++ b/src/pages/MessagesPage.tsx @@ -0,0 +1,290 @@ +import { useSeoMeta } from '@unhead/react'; +import { useMemo, useRef, useState, type FormEvent } from 'react'; +import { Navigate } from 'react-router-dom'; +import { useTranslation } from 'react-i18next'; +import { Loader2, Lock, MessageSquare, Send } from 'lucide-react'; + +import { PageHeader } from '@/components/PageHeader'; +import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { ScrollArea } from '@/components/ui/scroll-area'; +import { Skeleton } from '@/components/ui/skeleton'; +import { Card, CardContent } from '@/components/ui/card'; +import { useAppContext } from '@/hooks/useAppContext'; +import { useAuthor } from '@/hooks/useAuthor'; +import { useCurrentUser } from '@/hooks/useCurrentUser'; +import { + useDirectMessages, + useSendDirectMessage, + type Conversation, + type DirectMessage, +} from '@/hooks/useDirectMessages'; +import { useToast } from '@/hooks/useToast'; +import { getDisplayName } from '@/lib/genUserName'; +import { timeAgo } from '@/lib/timeAgo'; +import { cn } from '@/lib/utils'; + +/** Small helper bundling a peer's display name + avatar from kind-0 metadata. */ +function usePeerProfile(pubkey: string) { + const author = useAuthor(pubkey); + const metadata = author.data?.metadata; + return { + name: getDisplayName(metadata, pubkey), + picture: metadata?.picture, + }; +} + +/** A single row in the conversation list (left column). */ +function ConversationRow({ + conversation, + active, + onSelect, +}: { + conversation: Conversation; + active: boolean; + onSelect: () => void; +}) { + const { t } = useTranslation(); + const { name, picture } = usePeerProfile(conversation.peer); + const { latest } = conversation; + + const preview = + latest.content ?? + (latest.outgoing ? t('messages.encryptedSent') : t('messages.encryptedReceived')); + + return ( + + ); +} + +/** A single message bubble in the thread (right column). */ +function MessageBubble({ message }: { message: DirectMessage }) { + const { t } = useTranslation(); + return ( +
+
+ {message.content !== null ? ( +

{message.content}

+ ) : ( +

+ + {t('messages.decryptFailed')} +

+ )} +

+ {timeAgo(message.createdAt)} +

+
+
+ ); +} + +/** The active conversation thread plus a send composer. */ +function MessageThread({ conversation }: { conversation: Conversation }) { + const { t } = useTranslation(); + const { name, picture } = usePeerProfile(conversation.peer); + const { mutateAsync: send, isPending } = useSendDirectMessage(); + const { toast } = useToast(); + const [draft, setDraft] = useState(''); + const endRef = useRef(null); + + const handleSubmit = async (e: FormEvent) => { + e.preventDefault(); + const text = draft.trim(); + if (!text || isPending) return; + try { + await send({ peer: conversation.peer, text }); + setDraft(''); + endRef.current?.scrollIntoView({ behavior: 'smooth' }); + } catch (err) { + toast({ + title: t('messages.sendFailed'), + description: err instanceof Error ? err.message : undefined, + variant: 'destructive', + }); + } + }; + + return ( +
+
+ + + {name.charAt(0)} + +

{name}

+
+ + +
+ {conversation.messages.map((message) => ( + + ))} +
+
+ + +
+ setDraft(e.target.value)} + placeholder={t('messages.composePlaceholder')} + aria-label={t('messages.composePlaceholder')} + disabled={isPending} + /> + +
+
+ ); +} + +export function MessagesPage() { + const { t } = useTranslation(); + const { user } = useCurrentUser(); + const { config } = useAppContext(); + const { data: conversations, isLoading } = useDirectMessages(); + const [selectedPeer, setSelectedPeer] = useState(null); + + useSeoMeta({ + title: `${t('messages.title')} | ${config.appName}`, + description: t('messages.subtitle'), + }); + + const selected = useMemo( + () => conversations?.find((c) => c.peer === selectedPeer) ?? null, + [conversations, selectedPeer], + ); + + if (!user) { + return ; + } + + const hasDmSupport = !!user.signer.nip04; + + return ( +
+ } + contentClassName="max-w-5xl mx-auto w-full" + /> + +
+ {!hasDmSupport ? ( + + +

+ {t('messages.unsupported')} +

+
+
+ ) : ( +
+ {/* Conversation list */} +
+
+

+ {t('messages.conversations')} +

+
+ +
+ {isLoading ? ( + Array.from({ length: 5 }).map((_, i) => ( +
+ +
+ + +
+
+ )) + ) : conversations && conversations.length > 0 ? ( + conversations.map((conversation) => ( + setSelectedPeer(conversation.peer)} + /> + )) + ) : ( +
+

+ {t('messages.empty')} +

+
+ )} +
+
+
+ + {/* Thread */} +
+ {selected ? ( + + ) : ( +
+
+ +

+ {t('messages.selectPrompt')} +

+
+
+ )} +
+
+ )} +
+
+ ); +} + +export default MessagesPage;