## 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>
365 lines
14 KiB
TypeScript
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'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>
|
|
);
|
|
}
|