SkateSpots/src/pages/DirectMessagesPage.tsx
SkateSpots 2edf7a797e feat(ui): adopt the shared PageHeader on Wallet, CreateListing, GearSwap, Messages
PageHeader now carries the app's neon glow (accent-tinted text-shadow), so the
unified header keeps the brand look rather than flattening it. Four more plain
headers migrate (title + accent + optional subtitle/actions).
2026-09-26 19:23:01 +07:00

328 lines
13 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 { 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 } 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,
} 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, 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 (
<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]",
unread && "border-[#ff0040]/40 bg-[#ff0040]/[0.04]"
)}>
<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={cn(
"truncate text-sm sm:text-base",
unread ? "font-black text-white" : "font-bold text-white"
)}>{displayName}</p>
<div className="flex items-center gap-2 flex-shrink-0">
{timeAgo && (
<span className={cn(
"text-[10px] sm:text-xs",
unread ? "text-[#ff0040]" : "text-gray-500"
)}>{timeAgo}</span>
)}
{unread && (
<span
className="h-2.5 w-2.5 rounded-full bg-[#ff0040] shadow-[0_0_6px_rgba(255,0,64,0.9)]"
aria-label="Unread messages"
title="Unread messages"
/>
)}
</div>
</div>
{lastMessage && (
<p className={cn(
"text-xs sm:text-sm truncate",
unread ? "text-gray-200 font-medium" : "text-gray-400"
)}>
{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 { logins } = useNostrLogin();
const { data: conversations, isLoading, error, refetch } = useConversations();
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 (
<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>
<div className="mt-6 flex justify-center">
<LoginArea className="max-w-60" />
</div>
</CardContent>
</Card>
</div>
</Layout>
);
}
// 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 (
<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 />
<PageHeader
title="Messages"
icon={MessageCircle}
accent="#ff0040"
className="mb-4 sm:mb-6"
subtitle={
<span className="flex items-center gap-1">
<Lock className="h-3 w-3" />
<span className="hidden xs:inline">End-to-end encrypted with NIP-17</span>
<span className="xs:hidden">Encrypted</span>
</span>
}
actions={
<Button
onClick={() => setShowNewMessage(true)}
className="bg-[#ff0040] hover:bg-[#ff0040]/90 text-white font-bold 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>
}
/>
{/* 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>
<Button variant="outline" className="mt-4" onClick={() => refetch()}>
Try again
</Button>
</CardContent>
</Card>
) : conversations && conversations.length > 0 ? (
conversations.map((conv) => (
<ConversationPreview
key={conv.pubkey}
pubkey={conv.pubkey}
lastMessage={conv.lastMessage}
unread={isUnread(conv)}
/>
))
) : (
<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 — searchable people picker */}
<NewMessageDialog open={showNewMessage} onOpenChange={setShowNewMessage} />
</Layout>
);
}