Add a NIP-04 direct messages page

This commit is contained in:
lemon
2026-06-20 11:35:51 -07:00
parent e4b0f9a80d
commit 0e75799cf0
20 changed files with 707 additions and 1 deletions
+2
View File
@@ -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 -1
View File
@@ -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' />
+152
View File
@@ -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] });
},
});
}
+16
View File
@@ -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": "تسجيل الدخول",
+16
View File
@@ -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",
+16
View File
@@ -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",
+16
View File
@@ -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": "ورود",
+16
View File
@@ -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",
+16
View File
@@ -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": "लॉग इन करें",
+16
View File
@@ -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",
+16
View File
@@ -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": "ចូលគណនី",
+16
View File
@@ -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": "ننوتل",
+16
View File
@@ -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",
+16
View File
@@ -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": "Войти",
+16
View File
@@ -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",
+16
View File
@@ -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",
+16
View File
@@ -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",
+16
View File
@@ -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": "登入",
+16
View File
@@ -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": "登录",
+290
View File
@@ -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;