Add new-chat compose flow to /messages with inline recipient autocomplete

Adds a 'New message' button to the conversation list (and the empty-pane
prompt) that opens a fresh compose pane in the right column instead of a
modal. The pane has a 'To:' field that drives debounced profile search via
useSearchProfiles (name / NIP-05 / npub / nprofile), rendering suggestions
inline beneath the field — no popover. Arrow-key navigation + Enter select a
recipient; choosing one opens the normal thread (existing conversation or a
fresh blank thread). Adds the supporting locale strings across all locales.
This commit is contained in:
lemon
2026-06-23 09:26:06 -07:00
parent a76d53e686
commit cf9535ff39
17 changed files with 342 additions and 20 deletions
+9 -1
View File
@@ -122,7 +122,15 @@
"friendsEmpty": "لا توجد رسائل من الأشخاص الذين تتابعهم.",
"translateDialogTitle": "هل تريد ترجمة الرسائل الخاصة؟",
"translateDialogDescription": "سيتم إرسال الرسائل المستلمة إلى خدمة الترجمة التي قمت بتكوينها. لن تتم ترجمة الرسائل المرسلة. سيتذكر Agora هذا الاختيار على هذا الجهاز.",
"confirmTranslate": "ترجمة الرسائل"
"confirmTranslate": "ترجمة الرسائل",
"newMessage": "رسالة جديدة",
"newMessageTitle": "رسالة جديدة",
"recipientLabel": "إلى",
"recipientPlaceholder": "ابحث بالاسم أو NIP-05 أو npub…",
"recipientHint": "ابحث عن شخص لبدء محادثة جديدة.",
"recipientSearching": "جارٍ البحث…",
"noRecipientResults": "لم يُعثر على أشخاص. جرّب اسمًا أو NIP-05 أو npub مختلفًا.",
"recipientFollowed": "أنت تتابع هذا الشخص"
},
"auth": {
"join": "انضمام",
+9 -1
View File
@@ -123,7 +123,15 @@
"friendsEmpty": "No messages from people you follow.",
"translateDialogTitle": "Translate private messages?",
"translateDialogDescription": "Received messages will be sent to your configured translation service. Sent messages will not be translated. Agora will remember this choice on this device.",
"confirmTranslate": "Translate messages"
"confirmTranslate": "Translate messages",
"newMessage": "New message",
"newMessageTitle": "New message",
"recipientLabel": "To",
"recipientPlaceholder": "Search by name, NIP-05, or npub…",
"recipientHint": "Search for someone to start a new conversation.",
"recipientSearching": "Searching…",
"noRecipientResults": "No people found. Try a different name, NIP-05, or npub.",
"recipientFollowed": "You follow this person"
},
"auth": {
"join": "Join",
+9 -1
View File
@@ -122,7 +122,15 @@
"friendsEmpty": "No hay mensajes de personas que sigues.",
"translateDialogTitle": "¿Traducir mensajes privados?",
"translateDialogDescription": "Los mensajes recibidos se enviarán al servicio de traducción que configuraste. Los mensajes enviados no se traducirán. Agora recordará esta elección en este dispositivo.",
"confirmTranslate": "Traducir mensajes"
"confirmTranslate": "Traducir mensajes",
"newMessage": "Mensaje nuevo",
"newMessageTitle": "Mensaje nuevo",
"recipientLabel": "Para",
"recipientPlaceholder": "Busca por nombre, NIP-05 o npub…",
"recipientHint": "Busca a alguien para iniciar una nueva conversación.",
"recipientSearching": "Buscando…",
"noRecipientResults": "No se encontraron personas. Prueba con otro nombre, NIP-05 o npub.",
"recipientFollowed": "Sigues a esta persona"
},
"auth": {
"join": "Unirse",
+9 -1
View File
@@ -122,7 +122,15 @@
"friendsEmpty": "پیامی از افرادی که دنبال می‌کنید وجود ندارد.",
"translateDialogTitle": "پیام‌های خصوصی ترجمه شوند؟",
"translateDialogDescription": "پیام‌های دریافتی به سرویس ترجمه تنظیم‌شده شما ارسال می‌شوند. پیام‌های ارسالی ترجمه نخواهند شد. Agora این انتخاب را در این دستگاه به خاطر می‌سپارد.",
"confirmTranslate": "ترجمه پیام‌ها"
"confirmTranslate": "ترجمه پیام‌ها",
"newMessage": "پیام جدید",
"newMessageTitle": "پیام جدید",
"recipientLabel": "به",
"recipientPlaceholder": "جستجو بر اساس نام، NIP-05 یا npub…",
"recipientHint": "برای شروع یک گفتگوی جدید، کسی را جستجو کنید.",
"recipientSearching": "در حال جستجو…",
"noRecipientResults": "کسی یافت نشد. نام، NIP-05 یا npub دیگری را امتحان کنید.",
"recipientFollowed": "شما این فرد را دنبال می‌کنید"
},
"auth": {
"join": "پیوستن",
+9 -1
View File
@@ -122,7 +122,15 @@
"friendsEmpty": "Aucun message des personnes que vous suivez.",
"translateDialogTitle": "Traduire les messages privés ?",
"translateDialogDescription": "Les messages reçus seront envoyés au service de traduction configuré. Les messages envoyés ne seront pas traduits. Agora mémorisera ce choix sur cet appareil.",
"confirmTranslate": "Traduire les messages"
"confirmTranslate": "Traduire les messages",
"newMessage": "Nouveau message",
"newMessageTitle": "Nouveau message",
"recipientLabel": "À",
"recipientPlaceholder": "Rechercher par nom, NIP-05 ou npub…",
"recipientHint": "Recherchez quelqu'un pour démarrer une nouvelle conversation.",
"recipientSearching": "Recherche en cours…",
"noRecipientResults": "Aucune personne trouvée. Essayez un autre nom, NIP-05 ou npub.",
"recipientFollowed": "Vous suivez cette personne"
},
"auth": {
"join": "Rejoindre",
+9 -1
View File
@@ -122,7 +122,15 @@
"friendsEmpty": "जिन लोगों को आप फ़ॉलो करते हैं उनसे कोई संदेश नहीं।",
"translateDialogTitle": "निजी संदेशों का अनुवाद करें?",
"translateDialogDescription": "प्राप्त संदेश आपकी कॉन्फ़िगर की गई अनुवाद सेवा को भेजे जाएंगे। भेजे गए संदेशों का अनुवाद नहीं किया जाएगा। Agora इस डिवाइस पर यह विकल्प याद रखेगा।",
"confirmTranslate": "संदेशों का अनुवाद करें"
"confirmTranslate": "संदेशों का अनुवाद करें",
"newMessage": "नया संदेश",
"newMessageTitle": "नया संदेश",
"recipientLabel": "को",
"recipientPlaceholder": "नाम, NIP-05, या npub से खोजें…",
"recipientHint": "नई बातचीत शुरू करने के लिए किसी को खोजें।",
"recipientSearching": "खोज रहे हैं…",
"noRecipientResults": "कोई व्यक्ति नहीं मिला। कोई दूसरा नाम, NIP-05, या npub आज़माएं।",
"recipientFollowed": "आप इस व्यक्ति को फ़ॉलो करते हैं"
},
"auth": {
"join": "जुड़ें",
+9 -1
View File
@@ -122,7 +122,15 @@
"friendsEmpty": "Tidak ada pesan dari orang yang Anda ikuti.",
"translateDialogTitle": "Terjemahkan pesan pribadi?",
"translateDialogDescription": "Pesan yang diterima akan dikirim ke layanan terjemahan yang Anda konfigurasi. Pesan terkirim tidak akan diterjemahkan. Agora akan mengingat pilihan ini di perangkat ini.",
"confirmTranslate": "Terjemahkan pesan"
"confirmTranslate": "Terjemahkan pesan",
"newMessage": "Pesan baru",
"newMessageTitle": "Pesan baru",
"recipientLabel": "Kepada",
"recipientPlaceholder": "Cari berdasarkan nama, NIP-05, atau npub…",
"recipientHint": "Cari seseorang untuk memulai percakapan baru.",
"recipientSearching": "Mencari…",
"noRecipientResults": "Tidak ada orang ditemukan. Coba nama, NIP-05, atau npub yang berbeda.",
"recipientFollowed": "Anda mengikuti orang ini"
},
"auth": {
"join": "Gabung",
+9 -1
View File
@@ -122,7 +122,15 @@
"friendsEmpty": "គ្មានសារពីមនុស្សដែលអ្នកតាមដានទេ។",
"translateDialogTitle": "បកប្រែសារឯកជនឬ?",
"translateDialogDescription": "សារដែលបានទទួលនឹងត្រូវបានផ្ញើទៅសេវាបកប្រែដែលអ្នកបានកំណត់។ សារដែលបានផ្ញើនឹងមិនត្រូវបានបកប្រែទេ។ Agora នឹងចងចាំជម្រើសនេះនៅលើឧបករណ៍នេះ។",
"confirmTranslate": "បកប្រែសារ"
"confirmTranslate": "បកប្រែសារ",
"newMessage": "សារថ្មី",
"newMessageTitle": "សារថ្មី",
"recipientLabel": "ទៅ",
"recipientPlaceholder": "ស្វែងរកតាមឈ្មោះ, NIP-05, ឬ npub…",
"recipientHint": "ស្វែងរកនរណាម្នាក់ដើម្បីចាប់ផ្តើមការសន្ទនាថ្មី។",
"recipientSearching": "កំពុងស្វែងរក…",
"noRecipientResults": "រកមិនឃើញនរណាម្នាក់ទេ។ សាកល្បងឈ្មោះ, NIP-05, ឬ npub ផ្សេង។",
"recipientFollowed": "អ្នកតាមដានមនុស្សនេះ"
},
"auth": {
"join": "ចូលរួម",
+9 -1
View File
@@ -122,7 +122,15 @@
"friendsEmpty": "د هغو خلکو څخه پیغامونه نشته چې تاسو یې تعقیبوئ.",
"translateDialogTitle": "شخصي پیغامونه وژباړل شي؟",
"translateDialogDescription": "ترلاسه شوي پیغامونه به ستاسو ټاکل شوي ژباړې خدمت ته واستول شي. لېږل شوي پیغامونه به نه ژباړل کېږي. Agora به دا انتخاب په دې وسیله کې په یاد وساتي.",
"confirmTranslate": "پیغامونه وژباړئ"
"confirmTranslate": "پیغامونه وژباړئ",
"newMessage": "نوی پیغام",
"newMessageTitle": "نوی پیغام",
"recipientLabel": "ته",
"recipientPlaceholder": "د نوم، NIP-05 یا npub له مخې لټون وکړئ…",
"recipientHint": "د نوې خبرې اترې پیلولو لپاره څوک ولټوئ.",
"recipientSearching": "په لټون کې…",
"noRecipientResults": "هیڅ خلک ونه موندل شول. بل نوم، NIP-05 یا npub هڅه وکړئ.",
"recipientFollowed": "تاسو دا کس تعقیبوئ"
},
"auth": {
"join": "ګډون",
+9 -1
View File
@@ -122,7 +122,15 @@
"friendsEmpty": "Não há mensagens de pessoas que você segue.",
"translateDialogTitle": "Traduzir mensagens privadas?",
"translateDialogDescription": "As mensagens recebidas serão enviadas ao serviço de tradução configurado. As mensagens enviadas não serão traduzidas. O Agora lembrará esta escolha neste dispositivo.",
"confirmTranslate": "Traduzir mensagens"
"confirmTranslate": "Traduzir mensagens",
"newMessage": "Nova mensagem",
"newMessageTitle": "Nova mensagem",
"recipientLabel": "Para",
"recipientPlaceholder": "Pesquisar por nome, NIP-05 ou npub…",
"recipientHint": "Pesquise alguém para iniciar uma nova conversa.",
"recipientSearching": "Pesquisando…",
"noRecipientResults": "Nenhuma pessoa encontrada. Tente um nome, NIP-05 ou npub diferente.",
"recipientFollowed": "Você segue esta pessoa"
},
"auth": {
"join": "Entrar",
+9 -1
View File
@@ -122,7 +122,15 @@
"friendsEmpty": "Нет сообщений от людей, на которых вы подписаны.",
"translateDialogTitle": "Переводить личные сообщения?",
"translateDialogDescription": "Полученные сообщения будут отправляться в настроенный вами сервис перевода. Отправленные сообщения переводиться не будут. Agora запомнит этот выбор на этом устройстве.",
"confirmTranslate": "Переводить сообщения"
"confirmTranslate": "Переводить сообщения",
"newMessage": "Новое сообщение",
"newMessageTitle": "Новое сообщение",
"recipientLabel": "Кому",
"recipientPlaceholder": "Поиск по имени, NIP-05 или npub…",
"recipientHint": "Найдите кого-нибудь, чтобы начать новую беседу.",
"recipientSearching": "Поиск…",
"noRecipientResults": "Никто не найден. Попробуйте другое имя, NIP-05 или npub.",
"recipientFollowed": "Вы подписаны на этого человека"
},
"auth": {
"join": "Присоединиться",
+9 -1
View File
@@ -122,7 +122,15 @@
"friendsEmpty": "Hapana mameseji kubva kuvanhu vaunotevera.",
"translateDialogTitle": "Dudzira mameseji epachivande?",
"translateDialogDescription": "Mameseji agamuchirwa achaendeswa kusevhisi yako yekushandura yawakagadzirisa. Mameseji atumirwa haazodudzirwi. Agora icharangarira sarudzo iyi pachigadzirwa ichi.",
"confirmTranslate": "Dudzira mameseji"
"confirmTranslate": "Dudzira mameseji",
"newMessage": "Meseji itsva",
"newMessageTitle": "Meseji itsva",
"recipientLabel": "Kuna",
"recipientPlaceholder": "Tsvaga nezita, NIP-05, kana npub…",
"recipientHint": "Tsvaga munhu kuti utange nhaurirano itsva.",
"recipientSearching": "Kutsvaga…",
"noRecipientResults": "Hapana vanhu vawanikwa. Edza rimwe zita, NIP-05, kana npub.",
"recipientFollowed": "Unotevera munhu uyu"
},
"auth": {
"join": "Joinha",
+9 -1
View File
@@ -122,7 +122,15 @@
"friendsEmpty": "Hakuna ujumbe kutoka kwa watu unaowafuata.",
"translateDialogTitle": "Tafsiri ujumbe wa faragha?",
"translateDialogDescription": "Ujumbe uliopokelewa utatumwa kwa huduma yako ya tafsiri uliyosanidi. Ujumbe uliotumwa hautatafsiriwa. Agora itakumbuka chaguo hili kwenye kifaa hiki.",
"confirmTranslate": "Tafsiri ujumbe"
"confirmTranslate": "Tafsiri ujumbe",
"newMessage": "Ujumbe mpya",
"newMessageTitle": "Ujumbe mpya",
"recipientLabel": "Kwa",
"recipientPlaceholder": "Tafuta kwa jina, NIP-05, au npub…",
"recipientHint": "Tafuta mtu ili kuanza mazungumzo mapya.",
"recipientSearching": "Inatafuta…",
"noRecipientResults": "Hakuna watu waliopatikana. Jaribu jina, NIP-05, au npub tofauti.",
"recipientFollowed": "Unamfuata mtu huyu"
},
"auth": {
"join": "Jiunge",
+9 -1
View File
@@ -122,7 +122,15 @@
"friendsEmpty": "Takip ettiğiniz kişilerden mesaj yok.",
"translateDialogTitle": "Özel mesajlar çevrilsin mi?",
"translateDialogDescription": "Alınan mesajlar yapılandırdığınız çeviri hizmetine gönderilecek. Gönderilen mesajlar çevrilmeyecek. Agora bu tercihi bu cihazda hatırlayacak.",
"confirmTranslate": "Mesajları çevir"
"confirmTranslate": "Mesajları çevir",
"newMessage": "Yeni mesaj",
"newMessageTitle": "Yeni mesaj",
"recipientLabel": "Kime",
"recipientPlaceholder": "İsim, NIP-05 veya npub ile ara…",
"recipientHint": "Yeni bir sohbet başlatmak için birini arayın.",
"recipientSearching": "Aranıyor…",
"noRecipientResults": "Kimse bulunamadı. Farklı bir isim, NIP-05 veya npub deneyin.",
"recipientFollowed": "Bu kişiyi takip ediyorsunuz"
},
"auth": {
"join": "Katıl",
+9 -1
View File
@@ -122,7 +122,15 @@
"friendsEmpty": "沒有來自您追蹤的人的訊息。",
"translateDialogTitle": "要翻譯私人訊息嗎?",
"translateDialogDescription": "收到的訊息將會傳送至你設定的翻譯服務。已傳送的訊息不會被翻譯。Agora 會在此裝置上記住這個選擇。",
"confirmTranslate": "翻譯訊息"
"confirmTranslate": "翻譯訊息",
"newMessage": "新訊息",
"newMessageTitle": "新訊息",
"recipientLabel": "收件人",
"recipientPlaceholder": "按名稱、NIP-05 或 npub 搜尋…",
"recipientHint": "搜尋某人以開始新的對話。",
"recipientSearching": "正在搜尋…",
"noRecipientResults": "找不到任何人。請嘗試其他名稱、NIP-05 或 npub。",
"recipientFollowed": "你追蹤了此人"
},
"auth": {
"join": "加入",
+9 -1
View File
@@ -122,7 +122,15 @@
"friendsEmpty": "没有来自您关注的人的消息。",
"translateDialogTitle": "要翻译私信吗?",
"translateDialogDescription": "收到的消息将发送到你配置的翻译服务。已发送的消息不会被翻译。Agora 会在此设备上记住此选择。",
"confirmTranslate": "翻译消息"
"confirmTranslate": "翻译消息",
"newMessage": "新消息",
"newMessageTitle": "新消息",
"recipientLabel": "收件人",
"recipientPlaceholder": "按名称、NIP-05 或 npub 搜索…",
"recipientHint": "搜索某人以开始新的对话。",
"recipientSearching": "正在搜索…",
"noRecipientResults": "未找到任何人。请尝试其他名称、NIP-05 或 npub。",
"recipientFollowed": "你关注了此人"
},
"auth": {
"join": "加入",
+198 -4
View File
@@ -3,7 +3,8 @@ import { useEffect, useMemo, useRef, useState, type FormEvent, type KeyboardEven
import { Link, Navigate, useSearchParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { useInView } from 'react-intersection-observer';
import { ArrowLeft, ArrowUp, BellOff, Inbox, Loader2, Lock, MessageSquare, Search, UserCheck, X } from 'lucide-react';
import { ArrowLeft, ArrowUp, BellOff, Inbox, Loader2, Lock, MessageSquare, PenSquare, Search, UserCheck, X } from 'lucide-react';
import { nip19 } from 'nostr-tools';
import {
AlertDialog,
@@ -37,6 +38,7 @@ import {
import { useFollowList } from '@/hooks/useFollowActions';
import { useMuteList } from '@/hooks/useMuteList';
import { useProfileUrl } from '@/hooks/useProfileUrl';
import { useSearchProfiles, type SearchProfile } from '@/hooks/useSearchProfiles';
import { useToast } from '@/hooks/useToast';
import { useVerifierStatement } from '@/hooks/useVerifierStatement';
import { getDisplayName } from '@/lib/genUserName';
@@ -523,6 +525,164 @@ function MessageThread({
);
}
/** A single recipient suggestion row inside the new-message pane. */
function RecipientSuggestion({
profile,
active,
followed,
onSelect,
}: {
profile: SearchProfile;
active: boolean;
followed: boolean;
onSelect: () => void;
}) {
const { t } = useTranslation();
const name = getDisplayName(profile.metadata, profile.pubkey);
const picture = sanitizeUrl(profile.metadata.picture);
const handle = profile.metadata.nip05 ?? nip19.npubEncode(profile.pubkey);
return (
<button
type="button"
onClick={onSelect}
data-active={active}
className={cn(
'flex w-full items-center gap-3 rounded-2xl border border-transparent p-3 text-left transition-all',
'hover:border-border hover:bg-background/85 hover:shadow-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
active && 'border-primary/20 bg-primary/10 shadow-sm',
)}
>
<Avatar className="size-11 shrink-0 ring-2 ring-background">
<AvatarImage src={picture} alt={name} />
<AvatarFallback>{name.charAt(0)}</AvatarFallback>
</Avatar>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5">
<p className="truncate text-sm font-medium">{name}</p>
{followed && (
<UserCheck className="size-3.5 shrink-0 text-primary" aria-label={t('messages.recipientFollowed')} />
)}
</div>
<p className="truncate text-xs text-muted-foreground">{handle}</p>
</div>
</button>
);
}
/**
* The "start a new chat" pane. Renders in the right column in place of the
* empty-state prompt. A "To:" field drives debounced profile autocomplete
* (npub / nprofile / NIP-05 / name) and the suggestions render inline below
* the field — no modal, no popover. Selecting a person opens a fresh thread.
*/
function NewMessagePane({
onSelectRecipient,
onCancel,
}: {
onSelectRecipient: (pubkey: string) => void;
onCancel: () => void;
}) {
const { t } = useTranslation();
const [query, setQuery] = useState('');
const [activeIndex, setActiveIndex] = useState(0);
const { data: profiles, isFetching, followedPubkeys } = useSearchProfiles(query);
const trimmed = query.trim();
const results = useMemo(() => profiles ?? [], [profiles]);
useEffect(() => {
setActiveIndex(0);
}, [results]);
const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
if (results.length === 0) return;
if (e.key === 'ArrowDown') {
e.preventDefault();
setActiveIndex((i) => (i + 1) % results.length);
} else if (e.key === 'ArrowUp') {
e.preventDefault();
setActiveIndex((i) => (i - 1 + results.length) % results.length);
} else if (e.key === 'Enter') {
e.preventDefault();
const chosen = results[activeIndex];
if (chosen) onSelectRecipient(chosen.pubkey);
} else if (e.key === 'Escape') {
e.preventDefault();
onCancel();
}
};
return (
<div className="flex h-full min-h-0 flex-col bg-gradient-to-b from-muted/30 via-background to-background">
<div className="flex items-center gap-3 p-4">
<Button type="button" variant="ghost" size="icon" className="md:hidden" onClick={onCancel}>
<ArrowLeft className="size-4" />
<span className="sr-only">{t('messages.conversations')}</span>
</Button>
<p className="min-w-0 flex-1 truncate font-semibold">{t('messages.newMessageTitle')}</p>
<Button
type="button"
variant="ghost"
size="icon"
className="hidden shrink-0 md:inline-flex"
onClick={onCancel}
aria-label={t('common.cancel')}
>
<X className="size-4" />
</Button>
</div>
<div className="px-4 pb-3">
<label htmlFor="dm-recipient" className="mb-1.5 block text-xs font-medium text-muted-foreground">
{t('messages.recipientLabel')}
</label>
<div className="relative">
<Search className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
id="dm-recipient"
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={t('messages.recipientPlaceholder')}
aria-label={t('messages.recipientLabel')}
className="h-11 rounded-full border-0 bg-muted/40 pl-9 pr-9 text-base shadow-sm focus-visible:ring-2 focus-visible:ring-ring md:text-sm"
autoComplete="off"
autoFocus
/>
{isFetching && (
<Loader2 className="absolute right-3 top-1/2 size-4 -translate-y-1/2 animate-spin text-muted-foreground" />
)}
</div>
</div>
<ScrollArea className="min-h-0 flex-1 px-4">
<div className="space-y-1.5 pb-5">
{results.length > 0 ? (
results.map((profile, index) => (
<RecipientSuggestion
key={profile.pubkey}
profile={profile}
active={index === activeIndex}
followed={followedPubkeys.has(profile.pubkey)}
onSelect={() => onSelectRecipient(profile.pubkey)}
/>
))
) : (
<div className="flex flex-col items-center gap-3 px-6 py-12 text-center">
<PenSquare className="size-9 text-primary" />
<p className="mx-auto max-w-xs text-sm text-muted-foreground">
{trimmed
? (isFetching ? t('messages.recipientSearching') : t('messages.noRecipientResults'))
: t('messages.recipientHint')}
</p>
</div>
)}
</div>
</ScrollArea>
</div>
);
}
export function MessagesPage() {
const { t } = useTranslation();
const [searchParams, setSearchParams] = useSearchParams();
@@ -540,6 +700,7 @@ export function MessagesPage() {
const { muteItems } = useMuteList();
const requestedPeer = getValidPubkey(searchParams.get('to'));
const [selectedPeer, setSelectedPeer] = useState<string | null>(() => requestedPeer);
const [composing, setComposing] = useState(false);
const [search, setSearch] = useState('');
const [conversationFilter, setConversationFilter] = useState<'all' | 'friends'>('all');
const [hiddenMutedPeers, setHiddenMutedPeers] = useState<Set<string>>(() => new Set());
@@ -578,6 +739,7 @@ export function MessagesPage() {
const selectPeer = (peer: string) => {
setSelectedPeer(peer);
setComposing(false);
setSearchParams((current) => {
const next = new URLSearchParams(current);
next.delete('to');
@@ -587,6 +749,7 @@ export function MessagesPage() {
const clearSelectedPeer = () => {
setSelectedPeer(null);
setComposing(false);
setSearchParams((current) => {
const next = new URLSearchParams(current);
next.delete('to');
@@ -631,8 +794,8 @@ export function MessagesPage() {
<div
className={cn(
'h-full min-h-0 flex-col bg-muted/40',
selectedThread && 'hidden md:flex',
!selectedThread && 'flex',
(selectedThread || composing) && 'hidden md:flex',
!(selectedThread || composing) && 'flex',
)}
>
<ScrollArea className="min-h-0 flex-1">
@@ -659,6 +822,20 @@ export function MessagesPage() {
>
{conversationFilter === 'all' ? <Inbox className="size-4" /> : <UserCheck className="size-4" />}
</Button>
<Button
type="button"
variant="default"
size="icon"
className="size-10 shrink-0 rounded-full"
onClick={() => {
setSelectedPeer(null);
setComposing(true);
}}
aria-label={t('messages.newMessage')}
title={t('messages.newMessage')}
>
<PenSquare className="size-4" />
</Button>
</div>
{isLoading ? (
Array.from({ length: 5 }).map((_, i) => (
@@ -697,9 +874,14 @@ export function MessagesPage() {
</div>
{/* Thread */}
<div className={cn('h-full min-h-0 min-w-0', !selectedThread && 'hidden md:block')}>
<div className={cn('h-full min-h-0 min-w-0', !(selectedThread || composing) && 'hidden md:block')}>
{selectedThread ? (
<MessageThread conversation={selectedThread} onBack={clearSelectedPeer} onMuted={handleMuted} />
) : composing ? (
<NewMessagePane
onSelectRecipient={selectPeer}
onCancel={() => setComposing(false)}
/>
) : (
<div className="flex h-full items-center justify-center bg-gradient-to-br from-background via-muted/20 to-primary/5 p-8 text-center">
<div className="max-w-sm space-y-3">
@@ -707,6 +889,18 @@ export function MessagesPage() {
<p className="text-sm text-muted-foreground">
{t('messages.selectPrompt')}
</p>
<Button
type="button"
variant="outline"
className="rounded-full"
onClick={() => {
setSelectedPeer(null);
setComposing(true);
}}
>
<PenSquare className="mr-2 size-4" />
{t('messages.newMessage')}
</Button>
</div>
</div>
)}