SkateSpots/src/pages/DirectMessagesPage.tsx
shakespeare.diy f19851da92 security: implement all 8 protection measures
## 1. sanitizeUrl() (already existed, verified applied)
- Applied to ShopDetailPage, CoachDetailPage with urlDomain() tooltip hints
- Blocks javascript:, data:, vbscript: and all non-http(s) schemes
- Returns null for invalid URLs — link simply doesn't render

## 2. Geohash validation in parsers
- Added isValidGeohash() in types.ts using ngeohash.decode()
- Min 4 chars (country-level precision rejected), max 12 chars
- Rejects lat/lng outside Earth bounds
- Rejects 0,0 "null island" — classic bot artifact
- VPN/Tor unaffected: geohash comes from map pin, not user IP

## 3. Content length limits in all parsers
- MAX_TITLE_LEN=100, MAX_DESCRIPTION_LEN=3000, MAX_LOCATION_LEN=200
- MIN_TITLE_LEN=2, MIN_GEOHASH_LEN=4
- Applied to parseSkateSpot, parseSkateShop, parseSkateCoach,
  parseSkateFilmer, parseSkateEditor, parseSkateEvent

## 4. Smart WoT default (already in codebase, verified correct)
- Logged out / <5 follows → 'all' (don't punish new/anon users)
- Logged in with ≥5 follows → 'trusted' (sort trusted first)
- Soft mode: trusted content floats to top, nothing hidden
- Active in MapPage and EventsPage

## 5. Trusted count stats in useGrowthStats
- Added trustedSpots and trustedMappers to GrowthStats type
- Heuristic: pubkeys with ≥2 published items = organic community member
- CommunityStatsPage now shows "🛡 X from active accounts" below spot count
- Purely additive — both numbers always visible

## 6. OfficialBadge component (already existed, wired up)
- Added to SpotCard, EventCard, ShopDetailPage, CoachDetailPage
- Only renders for FOUNDER_PUBKEY (20e7c953...)
- Zero-friction: invisible for all other pubkeys
- Impersonation attempts immediately visible

## 7. Leaflet marker clustering
- Installed leaflet.markercluster v1.5.3 + @types
- MarkerClusterGroup replaces direct marker.addTo(map)
- Custom cluster icon: red/orange gradient circle with count
- maxClusterRadius: 60px, spiderfyOnMaxZoom: true
- MarkerCluster CSS loaded from CDN in index.html

## 8. DM phishing warning banner
- PhishingWarningBanner component in DirectMessagesPage
- One-time dismissible (localStorage key: dm-phishing-warning-dismissed)
- Message: SkateSpots will never DM you asking for keys/payments
- Uses useLocalStorage hook, Shield icon, X dismiss button

Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
2026-06-18 14:03:15 +07:00

365 lines
14 KiB
TypeScript

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 { Card, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
import { Skeleton } from '@/components/ui/skeleton';
import { Badge } from '@/components/ui/badge';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from '@/components/ui/dialog';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useConversations, formatPubkeyShort } from '@/hooks/useDirectMessages';
import { useAuthor } from '@/hooks/useAuthor';
import { genUserName } from '@/lib/genUserName';
import { useLocalStorage } from '@/hooks/useLocalStorage';
import { formatDistanceToNow } from 'date-fns';
import {
MessageCircle,
Plus,
Search,
Lock,
AlertCircle,
ShieldCheck,
X,
} 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<boolean>('dm-phishing-warning-dismissed', false);
if (dismissed) return null;
return (
<div className="flex items-start gap-3 px-4 py-3 mb-4 rounded-xl border border-[#ccff00]/30 bg-[#ccff00]/5 text-sm">
<ShieldCheck className="h-5 w-5 text-[#ccff00] flex-shrink-0 mt-0.5" />
<div className="flex-1 min-w-0">
<p className="text-[#ccff00] font-bold text-xs uppercase tracking-wide mb-0.5">
Security reminder
</p>
<p className="text-gray-300 text-xs leading-relaxed">
<strong className="text-white">SkateSpots will never DM you</strong> asking to verify your account,
share your keys, or send payments. If someone claims to be SkateSpots support via DM — it&apos;s a scam.
</p>
</div>
<button
onClick={() => setDismissed(true)}
className="flex-shrink-0 text-gray-500 hover:text-gray-300 transition-colors p-0.5 mt-0.5"
aria-label="Dismiss security reminder"
>
<X className="h-4 w-4" />
</button>
</div>
);
}
// Conversation preview component
function ConversationPreview({ pubkey, lastMessage }: {
pubkey: string;
lastMessage?: { content: string; createdAt: number; pubkey: string };
}) {
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 (
<Link to={`/messages/${nip19.npubEncode(pubkey)}`}>
<Card className={cn(
"hover:bg-gray-900/50 transition-colors cursor-pointer border-gray-800",
"hover:border-[#ff0040]/50 active:scale-[0.99]"
)}>
<CardContent className="p-3 sm:p-4">
<div className="flex items-center gap-2.5 sm:gap-3">
<Avatar className="h-10 w-10 sm:h-12 sm:w-12 border-2 border-gray-700 flex-shrink-0">
<AvatarImage src={avatar} alt={displayName} />
<AvatarFallback className="bg-[#ff0040]/20 text-[#ff0040] text-xs sm:text-sm">
{displayName.slice(0, 2).toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between gap-2">
<p className="font-bold text-white truncate text-sm sm:text-base">{displayName}</p>
{timeAgo && (
<span className="text-[10px] sm:text-xs text-gray-500 flex-shrink-0">{timeAgo}</span>
)}
</div>
{lastMessage && (
<p className="text-xs sm:text-sm text-gray-400 truncate">
{isFromMe && <span className="text-gray-500">You: </span>}
{lastMessage.content}
</p>
)}
</div>
</div>
</CardContent>
</Card>
</Link>
);
}
function ConversationSkeleton() {
return (
<Card className="border-gray-800">
<CardContent className="p-4">
<div className="flex items-center gap-3">
<Skeleton className="h-12 w-12 rounded-full" />
<div className="flex-1 space-y-2">
<Skeleton className="h-4 w-32" />
<Skeleton className="h-3 w-48" />
</div>
</div>
</CardContent>
</Card>
);
}
export default function DirectMessagesPage() {
const { user } = useCurrentUser();
const { data: conversations, isLoading, error } = useConversations();
const [showNewMessage, setShowNewMessage] = useState(false);
const [newRecipient, setNewRecipient] = useState('');
const [recipientError, setRecipientError] = useState('');
useSeoMeta({
title: 'Messages - SkateSpots',
description: 'Private encrypted messages with the skateboarding community',
});
const handleStartConversation = () => {
setRecipientError('');
// Try to parse the input as npub or hex pubkey
let pubkey = newRecipient.trim();
try {
if (pubkey.startsWith('npub1')) {
const decoded = nip19.decode(pubkey);
if (decoded.type === 'npub') {
pubkey = decoded.data;
} else {
throw new Error('Invalid npub');
}
} else if (!/^[a-f0-9]{64}$/i.test(pubkey)) {
throw new Error('Invalid pubkey format');
}
// Navigate to conversation
window.location.href = `/messages/${nip19.npubEncode(pubkey)}`;
} catch {
setRecipientError('Please enter a valid npub or pubkey');
}
};
// Not logged in
if (!user) {
return (
<Layout>
<div className="container px-4 py-8">
<Card className="border-dashed border-2 border-gray-700">
<CardContent className="py-16 text-center">
<Lock className="h-16 w-16 mx-auto mb-4 text-gray-600" />
<h2 className="text-xl font-bold mb-2">Log in to access messages</h2>
<p className="text-gray-400">
Private, encrypted messaging with skaters in the community
</p>
</CardContent>
</Card>
</div>
</Layout>
);
}
// Check if NIP-44 is available
if (!user.signer?.nip44) {
const isAndroid = /Android/i.test(navigator.userAgent);
const isIOS = /iPhone|iPad|iPod/i.test(navigator.userAgent);
const isMobile = isAndroid || isIOS;
return (
<Layout>
<div className="container px-4 py-8">
<Card className="border-dashed border-2 border-orange-500/30">
<CardContent className="py-12 sm:py-16 text-center px-4">
<AlertCircle className="h-12 w-12 sm:h-16 sm:w-16 mx-auto mb-4 text-orange-500" />
<h2 className="text-lg sm:text-xl font-bold mb-2">Secure Login Required</h2>
<p className="text-gray-400 mb-4 text-sm sm:text-base">
Private messages require a secure signer app to encrypt your conversations.
</p>
{isMobile ? (
<div className="space-y-3">
{isAndroid && (
<div className="p-4 rounded-lg bg-[#ccff00]/10 border border-[#ccff00]/30">
<p className="text-sm text-gray-300 mb-3">
<strong className="text-[#ccff00]">On Android:</strong> Use the Amber app
</p>
<a
href="https://github.com/greenart7c3/Amber"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 px-4 py-2 rounded-full bg-[#ccff00] text-black font-bold text-sm"
>
Get Amber →
</a>
</div>
)}
{isIOS && (
<div className="p-4 rounded-lg bg-blue-500/10 border border-blue-500/30">
<p className="text-sm text-gray-300">
<strong className="text-blue-400">On iOS:</strong> Try logging in with a bunker connection (nsec.app) from Settings
</p>
</div>
)}
</div>
) : (
<p className="text-sm text-gray-500">
Try logging in with a browser extension like Alby or nos2x that supports encryption.
</p>
)}
</CardContent>
</Card>
</div>
</Layout>
);
}
return (
<Layout>
<div className="container px-4 py-4 sm:py-8 max-w-2xl">
{/* Phishing warning — shown once, dismissible */}
<PhishingWarningBanner />
{/* Header */}
<div className="flex items-center justify-between gap-3 mb-4 sm:mb-6">
<div className="min-w-0 flex-1">
<h1 className="text-xl sm:text-2xl font-black uppercase tracking-wider flex items-center gap-2 sm:gap-3">
<MessageCircle className="h-5 w-5 sm:h-7 sm:w-7 text-[#ff0040] flex-shrink-0" />
<span className="truncate">MESSAGES</span>
</h1>
<p className="text-gray-400 text-xs sm:text-sm mt-1 flex items-center gap-1">
<Lock className="h-2.5 w-2.5 sm:h-3 sm:w-3" />
<span className="hidden xs:inline">End-to-end encrypted with NIP-17</span>
<span className="xs:hidden">Encrypted</span>
</p>
</div>
<Button
onClick={() => setShowNewMessage(true)}
className="bg-[#ff0040] hover:bg-[#ff0040]/90 text-white font-bold flex-shrink-0 h-10 sm:h-auto px-3 sm:px-4"
>
<Plus className="h-4 w-4 sm:mr-2" />
<span className="hidden sm:inline">New Message</span>
</Button>
</div>
{/* Conversations List */}
<div className="space-y-3">
{isLoading ? (
<>
<ConversationSkeleton />
<ConversationSkeleton />
<ConversationSkeleton />
</>
) : error ? (
<Card className="border-red-500/30">
<CardContent className="py-8 text-center">
<AlertCircle className="h-12 w-12 mx-auto mb-3 text-red-500" />
<p className="text-gray-400">Failed to load messages</p>
</CardContent>
</Card>
) : conversations && conversations.length > 0 ? (
conversations.map((conv) => (
<ConversationPreview
key={conv.pubkey}
pubkey={conv.pubkey}
lastMessage={conv.lastMessage}
/>
))
) : (
<Card className="border-dashed border-2 border-gray-700">
<CardContent className="py-12 text-center">
<MessageCircle className="h-16 w-16 mx-auto mb-4 text-gray-600" />
<h2 className="text-xl font-bold mb-2">No messages yet</h2>
<p className="text-gray-400 mb-6">
Start a conversation with someone in the community
</p>
<Button
onClick={() => setShowNewMessage(true)}
className="bg-[#ff0040] hover:bg-[#ff0040]/90 text-white font-bold"
>
<Plus className="h-4 w-4 mr-2" />
Start a Conversation
</Button>
</CardContent>
</Card>
)}
</div>
</div>
{/* New Message Dialog */}
<Dialog open={showNewMessage} onOpenChange={setShowNewMessage}>
<DialogContent className="bg-black border-2 border-[#ff0040]/50 mx-4 max-w-[calc(100vw-2rem)] sm:max-w-lg">
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-white">
<MessageCircle className="h-5 w-5 text-[#ff0040]" />
New Message
</DialogTitle>
<DialogDescription className="text-gray-400 text-sm">
Enter the npub or pubkey of the person you want to message
</DialogDescription>
</DialogHeader>
<div className="space-y-4 mt-4">
<div className="space-y-2">
<Input
value={newRecipient}
onChange={(e) => {
setNewRecipient(e.target.value);
setRecipientError('');
}}
placeholder="npub1... or hex pubkey"
className="bg-black border-gray-700 text-white font-mono text-sm h-11"
/>
{recipientError && (
<p className="text-sm text-red-500">{recipientError}</p>
)}
</div>
<div className="flex gap-2">
<Button
variant="outline"
onClick={() => setShowNewMessage(false)}
className="flex-1 border-gray-700 h-11"
>
Cancel
</Button>
<Button
onClick={handleStartConversation}
disabled={!newRecipient.trim()}
className="flex-1 bg-[#ff0040] hover:bg-[#ff0040]/90 text-white font-bold h-11"
>
Start Chat
</Button>
</div>
</div>
</DialogContent>
</Dialog>
</Layout>
);
}