import { useState } from 'react'; import { Link } from 'react-router-dom'; import { useSeoMeta } from '@unhead/react'; import { nip19 } from 'nostr-tools'; import { Layout } from '@/components/Layout'; 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 { 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 [dismissed, setDismissed] = useLocalStorage('dm-phishing-warning-dismissed', false); if (dismissed) return null; return (

Security reminder

SkateSpots will never DM you asking to verify your account, share your keys, or send payments. If someone claims to be SkateSpots support via DM — it's a scam.

); } // 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 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 (

{participants.length} people

{timeAgo}

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

{last && (

{isFromMe && You: } {last.content}

)}
); } function ConversationPreview({ pubkey, lastMessage, unread }: { pubkey: string; lastMessage?: { content: string; createdAt: number; pubkey: string }; unread?: boolean; }) { 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 && You: } {lastMessage.content}

)}
); } function ConversationSkeleton() { return (
); } export default function DirectMessagesPage() { const { user } = useCurrentUser(); const { logins } = useNostrLogin(); const { data: conversations, isLoading, error, refetch } = useConversations(); 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: 'Messages - SkateSpots', description: 'Private encrypted messages with the skateboarding community', }); // Not logged in if (!user) { return (

Log in to access messages

Private, encrypted messaging with skaters in the community

); } // 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 (

Secure Login Required

Private messages require a secure signer app to encrypt your conversations.

{isMobile ? (
{isAndroid && (

On Android: Use the Amber app

Get Amber →
)} {isIOS && (

On iOS: Try logging in with a bunker connection (nsec.app) from Settings

)}
) : (

Try logging in with a browser extension like Alby or nos2x that supports encryption.

)}
); } return (
{/* Phishing warning — shown once, dismissible */} End-to-end encrypted with NIP-17 Encrypted } actions={ } /> {/* Group conversations (NIP-17 group DMs) */} {groups && groups.length > 0 && (

Groups

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

Failed to load messages

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

No messages yet

Start a conversation with someone in the community

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