import { useState } from 'react'; import { Link } from 'react-router-dom'; import { useSeoMeta } from '@unhead/react'; import { nip19 } from 'nostr-tools'; import { PageHeader } from '@/components/PageHeader'; import { LoginArea } from '@/components/auth/LoginArea'; import { Card, CardContent } from '@/components/ui/card'; import { Button } from '@/components/ui/button'; import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar'; import { Skeleton } from '@/components/ui/skeleton'; import { NewMessageDialog } from '@/components/NewMessageDialog'; import { useTranslation } from '@/contexts/language'; import { useCurrentUser } from '@/hooks/useCurrentUser'; import { useNostrLogin } from '@nostrify/react/login'; import { hasNip44 as checkNip44 } from '@/lib/signerCapabilities'; import { useConversations, useGroupConversations, type Conversation } from '@/hooks/useDirectMessages'; import { useDMRead } from '@/hooks/useDMRead'; import { useAuthor } from '@/hooks/useAuthor'; import { genUserName } from '@/lib/genUserName'; import { useLocalStorage } from '@/hooks/useLocalStorage'; import { formatDistanceToNow } from 'date-fns'; import { MessageCircle, Plus, Lock, AlertCircle, ShieldCheck, X, Users, } from 'lucide-react'; import { cn } from '@/lib/utils'; /** * Dismissible phishing-warning banner shown once at the top of the DM page. * Standard practice (Alby, Metamask, Muun) — protects users from social-engineering * attacks where a bad actor pretends to be "SkateSpots Support" via DM. */ function PhishingWarningBanner() { const { t } = useTranslation(); const [dismissed, setDismissed] = useLocalStorage('dm-phishing-warning-dismissed', false); if (dismissed) return null; return (

{t('socialUi.dms.securityReminder')}

{t('socialUi.dms.phishingStrong')}{' '} {t('socialUi.dms.phishingRest')}

); } // Conversation preview component /** One participant's display name (own component so hooks aren't called in a loop). */ function GroupParticipantName({ pubkey }: { pubkey: string }) { const author = useAuthor(pubkey); return <>{author.data?.metadata?.name ?? genUserName(pubkey)}; } /** A group (multi-recipient) DM row in the inbox. */ function GroupPreview({ conversation }: { conversation: Conversation }) { const { t } = useTranslation(); const participants = conversation.participants ?? []; const { user } = useCurrentUser(); const last = conversation.lastMessage; const isFromMe = last?.pubkey === user?.pubkey; const timeAgo = last ? formatDistanceToNow(new Date(last.createdAt * 1000), { addSuffix: true }) : ''; return (

{t('socialUi.dms.peopleCount', { count: participants.length })}

{timeAgo}

{participants.slice(0, 3).map((p, i) => ( {i > 0 && ', '} ))} {participants.length > 3 && ` +${participants.length - 3}`}

{last && (

{isFromMe && {t('socialUi.dms.youPrefix')}} {last.content}

)}
); } function ConversationPreview({ pubkey, lastMessage, unread }: { pubkey: string; lastMessage?: { content: string; createdAt: number; pubkey: string }; unread?: boolean; }) { const { t } = useTranslation(); const author = useAuthor(pubkey); const { user } = useCurrentUser(); const metadata = author.data?.metadata; const displayName = metadata?.name ?? genUserName(pubkey); const avatar = metadata?.picture; const isFromMe = lastMessage?.pubkey === user?.pubkey; const timeAgo = lastMessage ? formatDistanceToNow(new Date(lastMessage.createdAt * 1000), { addSuffix: true }) : ''; return (
{displayName.slice(0, 2).toUpperCase()}

{displayName}

{timeAgo && ( {timeAgo} )} {unread && ( )}
{lastMessage && (

{isFromMe && {t('socialUi.dms.youPrefix')}} {lastMessage.content}

)}
); } function ConversationSkeleton() { return (
); } export default function DirectMessagesPage() { const { t } = useTranslation(); const { user } = useCurrentUser(); const { logins } = useNostrLogin(); const { data: inbox, isLoading, error, refetch } = useConversations(); const conversations = inbox?.conversations; const undecryptable = inbox?.undecryptable ?? 0; const { data: groups } = useGroupConversations(); const { getLastRead } = useDMRead(); const [showNewMessage, setShowNewMessage] = useState(false); // A conversation is unread when its latest message is from the OTHER person // and newer than the last time we opened that conversation. const isUnread = (conv: { pubkey: string; lastMessage?: { createdAt: number; pubkey: string } }) => { const lm = conv.lastMessage; if (!lm || lm.pubkey === user?.pubkey) return false; return lm.createdAt > getLastRead(conv.pubkey); }; useSeoMeta({ title: t('socialUi.dms.seoTitle'), description: t('socialUi.dms.seoDescription'), }); // Not logged in if (!user) { return ( <>

{t('socialUi.dms.loginTitle')}

{t('socialUi.dms.loginDesc')}

); } // Check if NIP-44 is available if (!checkNip44(user, logins)) { const isAndroid = /Android/i.test(navigator.userAgent); const isIOS = /iPhone|iPad|iPod/i.test(navigator.userAgent); const isMobile = isAndroid || isIOS; return ( <>

{t('socialUi.dms.secureLoginTitle')}

{t('socialUi.dms.secureLoginDesc')}

{isMobile ? (
{isAndroid && (

{t('socialUi.dms.onAndroid')} {t('socialUi.dms.useAmber')}

{t('socialUi.dms.getAmber')}
)} {isIOS && (

{t('socialUi.dms.onIOS')} {t('socialUi.dms.iosHint')}

)}
) : (

{t('socialUi.dms.browserHint')}

)}
); } return ( <>
{/* Phishing warning — shown once, dismissible */} {t('socialUi.dms.subtitleFull')} {t('socialUi.dms.subtitleShort')} } actions={ } /> {/* Group conversations (NIP-17 group DMs) */} {groups && groups.length > 0 && (

{t('socialUi.dms.groups')}

{groups.map((g) => ( ))}
)} {/* Conversations List */}
{isLoading ? ( <> ) : error ? (

{t('socialUi.dms.loadFailed')}

) : conversations && conversations.length > 0 ? ( conversations.map((conv) => ( )) ) : (

{t('socialUi.dms.emptyTitle')}

{t('socialUi.dms.emptyDesc')}

)} {/* Honest diagnostic: fetched-but-unreadable is NOT the same as an empty inbox. Without this, a decrypt failure looks identical to "nothing here" and is impossible to diagnose. */} {undecryptable > 0 && (

{undecryptable === 1 ? t('socialUi.dms.undecryptableOne', { count: undecryptable }) : t('socialUi.dms.undecryptableMany', { count: undecryptable })}

)}
{/* New Message — searchable people picker */} ); }