Request verification by message

This commit is contained in:
lemon
2026-06-20 22:38:49 -07:00
parent 978ee5f77e
commit 00579e5de2
19 changed files with 88 additions and 16 deletions
@@ -1,6 +1,7 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Loader2, Trash2 } from 'lucide-react';
import { Link } from 'react-router-dom';
import { Loader2, MessageSquare, Trash2 } from 'lucide-react';
import { PolicyMarkdown } from '@/components/PolicyMarkdown';
import {
@@ -81,7 +82,7 @@ export function ProfileVerifierSection({ pubkey, isOwnProfile = false, className
<h2 className="text-sm font-semibold uppercase tracking-wide text-primary">
{t('verifier.howWeVerifyTitle')}
</h2>
{isOwnProfile && (
{isOwnProfile ? (
<Button
type="button"
variant="ghost"
@@ -97,6 +98,19 @@ export function ProfileVerifierSection({ pubkey, isOwnProfile = false, className
)}
<span className="ml-1.5">{t('verifier.withdraw')}</span>
</Button>
) : (
<Button
type="button"
variant="secondary"
size="sm"
className="-mt-1 -mr-2 h-8 shrink-0 px-3 text-xs"
asChild
>
<Link to={`/messages?to=${pubkey}`}>
<MessageSquare className="mr-1.5 size-3.5" />
{t('verifier.requestVerification')}
</Link>
</Button>
)}
</div>
<PolicyMarkdown source={statement} />
+10 -2
View File
@@ -34,6 +34,13 @@ export interface Conversation {
latest: DirectMessage;
}
/** Minimal thread target; used for existing conversations and blank outbound threads. */
export interface DirectMessageThreadTarget {
peer: string;
events: NostrEvent[];
messageCount: number;
}
interface DirectMessagesPage {
conversations: Conversation[];
sentUntil: number | null;
@@ -301,17 +308,18 @@ async function decryptMessage({
}
/** Decrypts a selected thread on demand instead of blocking the inbox list. */
export function useDirectMessageThread(conversation: Conversation | null) {
export function useDirectMessageThread(conversation: DirectMessageThreadTarget | null) {
const { user } = useCurrentUser();
const self = user?.pubkey;
const nip04 = user?.signer.nip04;
const latestId = conversation?.events.at(-1)?.id ?? '';
return useQuery<DirectMessage[]>({
queryKey: [
'direct-message-thread',
self,
conversation?.peer,
conversation?.latest.id,
latestId,
conversation?.messageCount,
],
enabled: !!self && !!nip04 && !!conversation,
+1
View File
@@ -1128,6 +1128,7 @@
"loginGateTitle": "سجّل الدخول لتصبح مُحقِّقاً",
"loginGateBody": "يجب أن تكون مسجلاً للدخول لنشر بيان تحقق.",
"howWeVerifyTitle": "كيف نتحقّق",
"requestVerification": "طلب التحقق",
"withdrawConfirmTitle": "هل تريد سحب بيان التوثيق الخاص بك؟",
"withdrawConfirmBody": "سيُزال بيان «كيف نتحقّق» من ملفك الشخصي. يمكنك نشر بيان جديد في أي وقت."
},
+2 -1
View File
@@ -1660,7 +1660,8 @@
"disclaimer": "Anyone can become a verifier. Your statement is published publicly to Nostr and shown prominently on your profile. It does not grant any special permissions — it simply tells donors how you vet campaigns.",
"loginGateTitle": "Sign in to become a verifier",
"loginGateBody": "You need to be signed in to publish a verifier statement.",
"howWeVerifyTitle": "How We Verify"
"howWeVerifyTitle": "How We Verify",
"requestVerification": "Request Verification"
},
"mdEditor": {
"toolbar": {
+1
View File
@@ -1128,6 +1128,7 @@
"loginGateTitle": "Inicia sesión para convertirte en verificador",
"loginGateBody": "Debes haber iniciado sesión para publicar una declaración de verificador.",
"howWeVerifyTitle": "Cómo verificamos",
"requestVerification": "Solicitar verificación",
"withdrawConfirmTitle": "¿Retirar tu declaración de verificador?",
"withdrawConfirmBody": "Tu declaración «Cómo verificamos» se eliminará de tu perfil. Puedes publicar una nueva en cualquier momento."
},
+1
View File
@@ -1128,6 +1128,7 @@
"loginGateTitle": "برای تأییدکننده‌شدن وارد شوید",
"loginGateBody": "برای انتشار بیانیهٔ تأییدکننده باید وارد شده باشید.",
"howWeVerifyTitle": "چگونه تأیید می‌کنیم",
"requestVerification": "درخواست تأیید",
"withdrawConfirmTitle": "بیانیهٔ تأییدکنندهٔ خود را پس می‌گیرید؟",
"withdrawConfirmBody": "بیانیهٔ «چگونه تأیید می‌کنیم» شما از نمایه‌تان حذف می‌شود. هر زمان می‌توانید بیانیهٔ جدیدی منتشر کنید."
},
+1
View File
@@ -1570,6 +1570,7 @@
"loginGateTitle": "Connectez-vous pour devenir vérificateur",
"loginGateBody": "Vous devez être connecté pour publier une déclaration de vérificateur.",
"howWeVerifyTitle": "Comment nous vérifions",
"requestVerification": "Demander une vérification",
"withdrawConfirmTitle": "Retirer votre déclaration de vérificateur ?",
"withdrawConfirmBody": "Votre déclaration « Comment nous vérifions » sera retirée de votre profil. Vous pourrez en publier une nouvelle à tout moment."
},
+1
View File
@@ -1570,6 +1570,7 @@
"loginGateTitle": "सत्यापनकर्ता बनने के लिए साइन इन करें",
"loginGateBody": "सत्यापनकर्ता वक्तव्य प्रकाशित करने के लिए आपको साइन इन होना आवश्यक है।",
"howWeVerifyTitle": "हम कैसे सत्यापित करते हैं",
"requestVerification": "सत्यापन का अनुरोध करें",
"withdrawConfirmTitle": "अपना सत्यापनकर्ता वक्तव्य वापस लें?",
"withdrawConfirmBody": "आपका \"हम कैसे सत्यापित करते हैं\" वक्तव्य आपकी प्रोफ़ाइल से हटा दिया जाएगा। आप कभी भी नया प्रकाशित कर सकते हैं।"
},
+1
View File
@@ -1570,6 +1570,7 @@
"loginGateTitle": "Masuk untuk menjadi verifikator",
"loginGateBody": "Anda harus masuk untuk memublikasikan pernyataan verifikator.",
"howWeVerifyTitle": "Cara kami memverifikasi",
"requestVerification": "Minta Verifikasi",
"withdrawConfirmTitle": "Tarik pernyataan verifikator Anda?",
"withdrawConfirmBody": "Pernyataan \"Cara Kami Memverifikasi\" Anda akan dihapus dari profil Anda. Anda bisa memublikasikan yang baru kapan saja."
},
+1
View File
@@ -1128,6 +1128,7 @@
"loginGateTitle": "ចូលគណនីដើម្បីក្លាយជាអ្នកផ្ទៀងផ្ទាត់",
"loginGateBody": "អ្នកត្រូវចូលគណនីដើម្បីផ្សព្វផ្សាយសេចក្តីថ្លែងការណ៍អ្នកផ្ទៀងផ្ទាត់។",
"howWeVerifyTitle": "របៀបដែលយើងផ្ទៀងផ្ទាត់",
"requestVerification": "ស្នើសុំការផ្ទៀងផ្ទាត់",
"withdrawConfirmTitle": "ដកសេចក្តីថ្លែងការណ៍អ្នកផ្ទៀងផ្ទាត់របស់អ្នកវិញ?",
"withdrawConfirmBody": "សេចក្តីថ្លែងការណ៍ «របៀបដែលយើងផ្ទៀងផ្ទាត់» របស់អ្នកនឹងត្រូវដកចេញពីប្រវត្តិរូបរបស់អ្នក។ អ្នកអាចផ្សព្វផ្សាយមួយថ្មីបាននៅពេលណាក៏បាន។"
},
+1
View File
@@ -1128,6 +1128,7 @@
"loginGateTitle": "د تصدیق کوونکي کیدو لپاره ننوځئ",
"loginGateBody": "د تصدیق کوونکي بیان د خپرولو لپاره باید ننوتلي اوسئ.",
"howWeVerifyTitle": "موږ څنګه تصدیق کوو",
"requestVerification": "د تایید غوښتنه وکړئ",
"withdrawConfirmTitle": "خپل د تصدیق کوونکي بیان بیرته اخلئ؟",
"withdrawConfirmBody": "ستاسو د \"موږ څنګه تصدیق کوو\" بیان به ستاسو له پروفایل څخه لرې شي. تاسو کولی شئ هر وخت یو نوی خپور کړئ."
},
+1
View File
@@ -1570,6 +1570,7 @@
"loginGateTitle": "Entre para se tornar um verificador",
"loginGateBody": "Você precisa estar conectado para publicar uma declaração de verificador.",
"howWeVerifyTitle": "Como verificamos",
"requestVerification": "Solicitar verificação",
"withdrawConfirmTitle": "Retirar sua declaração de verificador?",
"withdrawConfirmBody": "Sua declaração \"Como verificamos\" será removida do seu perfil. Você pode publicar uma nova a qualquer momento."
},
+1
View File
@@ -1570,6 +1570,7 @@
"loginGateTitle": "Войдите, чтобы стать верификатором",
"loginGateBody": "Вы должны войти, чтобы опубликовать заявление верификатора.",
"howWeVerifyTitle": "Как мы проверяем",
"requestVerification": "Запросить проверку",
"withdrawConfirmTitle": "Отозвать ваше заявление верификатора?",
"withdrawConfirmBody": "Ваше заявление «Как мы проверяем» будет удалено из вашего профиля. Вы можете опубликовать новое в любой момент."
},
+1
View File
@@ -1128,6 +1128,7 @@
"loginGateTitle": "Pinda kuti uve musimbisi",
"loginGateBody": "Unofanira kunge wakapinda kuti uburitse chirevo chemusimbisi.",
"howWeVerifyTitle": "Matiro atinosimbisa nawo",
"requestVerification": "Kumbira kusimbiswa",
"withdrawConfirmTitle": "Bvisa chirevo chako chemusimbisi?",
"withdrawConfirmBody": "Chirevo chako che\"Matiro Atinosimbisa Nawo\" chichabviswa pa profile yako. Unogona kuburitsa chitsva chero nguva."
},
+1
View File
@@ -1570,6 +1570,7 @@
"loginGateTitle": "Ingia ili kuwa mthibitishaji",
"loginGateBody": "Unahitaji kuwa umeingia ili kuchapisha taarifa ya mthibitishaji.",
"howWeVerifyTitle": "Jinsi tunavyothibitisha",
"requestVerification": "Omba uthibitishaji",
"withdrawConfirmTitle": "Ondoa taarifa yako ya mthibitishaji?",
"withdrawConfirmBody": "Taarifa yako ya \"Jinsi Tunavyothibitisha\" itaondolewa kwenye wasifu wako. Unaweza kuchapisha mpya wakati wowote."
},
+1
View File
@@ -1570,6 +1570,7 @@
"loginGateTitle": "Doğrulayıcı olmak için giriş yapın",
"loginGateBody": "Doğrulayıcı beyanı yayımlamak için giriş yapmış olmanız gerekir.",
"howWeVerifyTitle": "Nasıl doğrularız",
"requestVerification": "Doğrulama iste",
"withdrawConfirmTitle": "Doğrulayıcı beyanınız geri çekilsin mi?",
"withdrawConfirmBody": "\"Nasıl Doğrularız\" beyanınız profilinizden kaldırılacak. İstediğiniz zaman yenisini yayımlayabilirsiniz."
},
+1
View File
@@ -1128,6 +1128,7 @@
"loginGateTitle": "登入以成為驗證者",
"loginGateBody": "你需要登入才能發布驗證者聲明。",
"howWeVerifyTitle": "我們如何驗證",
"requestVerification": "請求驗證",
"withdrawConfirmTitle": "撤回你的驗證者聲明?",
"withdrawConfirmBody": "你的「我們如何驗證」聲明將從你的個人資料中移除。你可以隨時發布新的聲明。"
},
+1
View File
@@ -1128,6 +1128,7 @@
"loginGateTitle": "登录以成为验证者",
"loginGateBody": "你需要登录才能发布验证者声明。",
"howWeVerifyTitle": "我们如何验证",
"requestVerification": "请求验证",
"withdrawConfirmTitle": "撤回你的验证者声明?",
"withdrawConfirmBody": "你的「我们如何验证」声明将从你的个人资料中移除。你可以随时发布新的声明。"
},
+45 -11
View File
@@ -1,6 +1,6 @@
import { useSeoMeta } from '@unhead/react';
import { useEffect, useMemo, useRef, useState, type FormEvent, type KeyboardEvent } from 'react';
import { Link, Navigate } from 'react-router-dom';
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';
@@ -32,6 +32,7 @@ import {
useSendDirectMessage,
type Conversation,
type DirectMessage,
type DirectMessageThreadTarget,
} from '@/hooks/useDirectMessages';
import { useFollowList } from '@/hooks/useFollowActions';
import { useMuteList } from '@/hooks/useMuteList';
@@ -46,6 +47,10 @@ import { cn } from '@/lib/utils';
const DM_TRANSLATION_CONFIRM_KEY = 'agora.dmTranslationConfirmed';
const DISMISSED_VERIFIER_STATEMENTS_KEY = 'agora.dismissedVerifierStatementBanners';
function getValidPubkey(value: string | null): string | null {
return value && /^[0-9a-f]{64}$/i.test(value) ? value.toLowerCase() : null;
}
function readDismissedVerifierStatements(): Set<string> {
if (typeof window === 'undefined') return new Set();
@@ -206,7 +211,7 @@ function MessageThread({
onBack,
onMuted,
}: {
conversation: Conversation;
conversation: DirectMessageThreadTarget;
onBack: () => void;
onMuted: (peer: string) => void;
}) {
@@ -520,6 +525,7 @@ function MessageThread({
export function MessagesPage() {
const { t } = useTranslation();
const [searchParams, setSearchParams] = useSearchParams();
const { user } = useCurrentUser();
const { config } = useAppContext();
const {
@@ -532,7 +538,8 @@ export function MessagesPage() {
} = useDirectMessages();
const { data: followData } = useFollowList();
const { muteItems } = useMuteList();
const [selectedPeer, setSelectedPeer] = useState<string | null>(null);
const requestedPeer = getValidPubkey(searchParams.get('to'));
const [selectedPeer, setSelectedPeer] = useState<string | null>(() => requestedPeer);
const [search, setSearch] = useState('');
const [conversationFilter, setConversationFilter] = useState<'all' | 'friends'>('all');
const [hiddenMutedPeers, setHiddenMutedPeers] = useState<Set<string>>(() => new Set());
@@ -555,14 +562,41 @@ export function MessagesPage() {
)),
[conversationFilter, conversations, followedPubkeys, hiddenMutedPeers, mutedPubkeys],
);
const selected = useMemo(
const selectedConversation = useMemo(
() => visibleConversations?.find((c) => c.peer === selectedPeer) ?? null,
[visibleConversations, selectedPeer],
);
const selectedThread = useMemo<DirectMessageThreadTarget | null>(() => {
if (selectedConversation) return selectedConversation;
if (!selectedPeer) return null;
return { peer: selectedPeer, events: [], messageCount: 0 };
}, [selectedConversation, selectedPeer]);
useEffect(() => {
if (requestedPeer) setSelectedPeer(requestedPeer);
}, [requestedPeer]);
const selectPeer = (peer: string) => {
setSelectedPeer(peer);
setSearchParams((current) => {
const next = new URLSearchParams(current);
next.delete('to');
return next;
}, { replace: true });
};
const clearSelectedPeer = () => {
setSelectedPeer(null);
setSearchParams((current) => {
const next = new URLSearchParams(current);
next.delete('to');
return next;
}, { replace: true });
};
const handleMuted = (peer: string) => {
setHiddenMutedPeers((prev) => new Set(prev).add(peer));
setSelectedPeer(null);
clearSelectedPeer();
};
useEffect(() => {
@@ -597,8 +631,8 @@ export function MessagesPage() {
<div
className={cn(
'h-full min-h-0 flex-col bg-muted/40',
selected && 'hidden md:flex',
!selected && 'flex',
selectedThread && 'hidden md:flex',
!selectedThread && 'flex',
)}
>
<ScrollArea className="min-h-0 flex-1">
@@ -643,7 +677,7 @@ export function MessagesPage() {
conversation={conversation}
active={conversation.peer === selectedPeer}
searchQuery={search}
onSelect={() => setSelectedPeer(conversation.peer)}
onSelect={() => selectPeer(conversation.peer)}
/>
))
) : (
@@ -663,9 +697,9 @@ export function MessagesPage() {
</div>
{/* Thread */}
<div className={cn('h-full min-h-0 min-w-0', !selected && 'hidden md:block')}>
{selected ? (
<MessageThread conversation={selected} onBack={() => setSelectedPeer(null)} onMuted={handleMuted} />
<div className={cn('h-full min-h-0 min-w-0', !selectedThread && 'hidden md:block')}>
{selectedThread ? (
<MessageThread conversation={selectedThread} onBack={clearSelectedPeer} onMuted={handleMuted} />
) : (
<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">