Add a NIP-04 direct messages page
This commit is contained in:
@@ -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() {
|
||||
<Route element={<FundraiserLayout narrow />}>
|
||||
<Route path="/feed" element={<Index />} />
|
||||
<Route path="/my-dashboard" element={<MyDashboardPage />} />
|
||||
<Route path="/messages" element={<MessagesPage />} />
|
||||
<Route path="/notifications" element={<NotificationsPage />} />
|
||||
<Route path="/search" element={<SearchPage />} />
|
||||
<Route path="/profile" element={<ProfileRedirect />} />
|
||||
|
||||
@@ -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) {
|
||||
<span>{t('nav.wallet')}</span>
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem asChild className='flex items-center gap-2 cursor-pointer p-2 rounded-md'>
|
||||
<Link to="/messages">
|
||||
<MessageSquare className='w-4 h-4' />
|
||||
<span>{t('nav.messages')}</span>
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem asChild className='flex items-center gap-2 cursor-pointer p-2 rounded-md'>
|
||||
<Link to="/notifications">
|
||||
<Bell className='w-4 h-4' />
|
||||
|
||||
@@ -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<Conversation[]>({
|
||||
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<string, NostrEvent>();
|
||||
for (const event of events) byId.set(event.id, event);
|
||||
|
||||
// Group by counterparty pubkey.
|
||||
const byPeer = new Map<string, NostrEvent[]>();
|
||||
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] });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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": "تسجيل الدخول",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "ورود",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "लॉग इन करें",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "ចូលគណនី",
|
||||
|
||||
@@ -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": "ننوتل",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Войти",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "登入",
|
||||
|
||||
@@ -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": "登录",
|
||||
|
||||
@@ -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 (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSelect}
|
||||
className={cn(
|
||||
'flex w-full items-center gap-3 rounded-lg p-3 text-left transition-colors',
|
||||
'hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
|
||||
active && 'bg-accent',
|
||||
)}
|
||||
>
|
||||
<Avatar className="size-10 shrink-0">
|
||||
<AvatarImage src={picture} alt={name} />
|
||||
<AvatarFallback>{name.charAt(0)}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
<p className="truncate text-sm font-medium">{name}</p>
|
||||
<span className="shrink-0 text-xs text-muted-foreground">
|
||||
{timeAgo(latest.createdAt)}
|
||||
</span>
|
||||
</div>
|
||||
<p className="truncate text-sm text-muted-foreground">
|
||||
{latest.outgoing && <span className="text-muted-foreground/70">{t('messages.youPrefix')} </span>}
|
||||
{preview}
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/** A single message bubble in the thread (right column). */
|
||||
function MessageBubble({ message }: { message: DirectMessage }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className={cn('flex', message.outgoing ? 'justify-end' : 'justify-start')}>
|
||||
<div
|
||||
className={cn(
|
||||
'max-w-[75%] rounded-2xl px-4 py-2 text-sm',
|
||||
message.outgoing
|
||||
? 'rounded-br-sm bg-primary text-primary-foreground'
|
||||
: 'rounded-bl-sm bg-muted text-foreground',
|
||||
)}
|
||||
>
|
||||
{message.content !== null ? (
|
||||
<p className="whitespace-pre-wrap break-words">{message.content}</p>
|
||||
) : (
|
||||
<p className="flex items-center gap-1.5 italic opacity-80">
|
||||
<Lock className="size-3.5" />
|
||||
{t('messages.decryptFailed')}
|
||||
</p>
|
||||
)}
|
||||
<p
|
||||
className={cn(
|
||||
'mt-1 text-[10px]',
|
||||
message.outgoing ? 'text-primary-foreground/70' : 'text-muted-foreground',
|
||||
)}
|
||||
>
|
||||
{timeAgo(message.createdAt)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 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<HTMLDivElement>(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 (
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="flex items-center gap-3 border-b p-4">
|
||||
<Avatar className="size-9">
|
||||
<AvatarImage src={picture} alt={name} />
|
||||
<AvatarFallback>{name.charAt(0)}</AvatarFallback>
|
||||
</Avatar>
|
||||
<p className="truncate font-medium">{name}</p>
|
||||
</div>
|
||||
|
||||
<ScrollArea className="flex-1 px-4">
|
||||
<div className="space-y-2 py-4">
|
||||
{conversation.messages.map((message) => (
|
||||
<MessageBubble key={message.id} message={message} />
|
||||
))}
|
||||
<div ref={endRef} />
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
<form onSubmit={handleSubmit} className="flex items-center gap-2 border-t p-3">
|
||||
<Input
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
placeholder={t('messages.composePlaceholder')}
|
||||
aria-label={t('messages.composePlaceholder')}
|
||||
disabled={isPending}
|
||||
/>
|
||||
<Button type="submit" size="icon" disabled={isPending || !draft.trim()}>
|
||||
{isPending ? <Loader2 className="size-4 animate-spin" /> : <Send className="size-4" />}
|
||||
<span className="sr-only">{t('messages.send')}</span>
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function MessagesPage() {
|
||||
const { t } = useTranslation();
|
||||
const { user } = useCurrentUser();
|
||||
const { config } = useAppContext();
|
||||
const { data: conversations, isLoading } = useDirectMessages();
|
||||
const [selectedPeer, setSelectedPeer] = useState<string | null>(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 <Navigate to="/" replace />;
|
||||
}
|
||||
|
||||
const hasDmSupport = !!user.signer.nip04;
|
||||
|
||||
return (
|
||||
<main>
|
||||
<PageHeader
|
||||
title={t('messages.title')}
|
||||
icon={<MessageSquare className="size-5" />}
|
||||
contentClassName="max-w-5xl mx-auto w-full"
|
||||
/>
|
||||
|
||||
<div className="mx-auto w-full max-w-5xl p-4">
|
||||
{!hasDmSupport ? (
|
||||
<Card className="border-dashed">
|
||||
<CardContent className="px-8 py-12 text-center">
|
||||
<p className="mx-auto max-w-sm text-muted-foreground">
|
||||
{t('messages.unsupported')}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="grid h-[70vh] grid-cols-1 overflow-hidden rounded-xl border md:grid-cols-[20rem_1fr]">
|
||||
{/* Conversation list */}
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-col border-r',
|
||||
selected && 'hidden md:flex',
|
||||
)}
|
||||
>
|
||||
<div className="border-b p-3">
|
||||
<h2 className="text-sm font-semibold text-muted-foreground">
|
||||
{t('messages.conversations')}
|
||||
</h2>
|
||||
</div>
|
||||
<ScrollArea className="flex-1">
|
||||
<div className="space-y-1 p-2">
|
||||
{isLoading ? (
|
||||
Array.from({ length: 5 }).map((_, i) => (
|
||||
<div key={i} className="flex items-center gap-3 p-3">
|
||||
<Skeleton className="size-10 rounded-full" />
|
||||
<div className="flex-1 space-y-1.5">
|
||||
<Skeleton className="h-3.5 w-24" />
|
||||
<Skeleton className="h-3 w-40" />
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : conversations && conversations.length > 0 ? (
|
||||
conversations.map((conversation) => (
|
||||
<ConversationRow
|
||||
key={conversation.peer}
|
||||
conversation={conversation}
|
||||
active={conversation.peer === selectedPeer}
|
||||
onSelect={() => setSelectedPeer(conversation.peer)}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<div className="px-4 py-12 text-center">
|
||||
<p className="mx-auto max-w-xs text-sm text-muted-foreground">
|
||||
{t('messages.empty')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
|
||||
{/* Thread */}
|
||||
<div className={cn('min-w-0', !selected && 'hidden md:block')}>
|
||||
{selected ? (
|
||||
<MessageThread conversation={selected} />
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center p-8 text-center">
|
||||
<div className="space-y-2">
|
||||
<MessageSquare className="mx-auto size-8 text-muted-foreground" />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('messages.selectPrompt')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
export default MessagesPage;
|
||||
Reference in New Issue
Block a user