Uses the existing gift-wrap primitive (kind 14 rumor, one p-tag per recipient): - useGroupConversations: lists multi-recipient threads, bucketed by the sorted participant key; the inbox shows a Groups section - GroupConversationPage (/messages/group/:participants): encrypted group thread with sender names, send + error feedback - smoke test now boots the new route Group creation UI (multi-select in NewMessageDialog) is the next step. For stronger guarantees (forward secrecy, rosters) Marmot/MLS or Cordn would be the protocol to adopt later; noted in the hook.
399 lines
15 KiB
TypeScript
399 lines
15 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, 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<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
|
|
/** 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 (
|
|
<Link to={`/messages/group/${participants.join(',')}`}>
|
|
<Card className={cn(
|
|
'hover:bg-gray-900/50 transition-colors cursor-pointer border-gray-800',
|
|
'hover:border-[#ccff00]/50 active:scale-[0.99]',
|
|
)}>
|
|
<CardContent className="p-3">
|
|
<div className="flex items-center gap-3">
|
|
<div className="flex h-11 w-11 flex-shrink-0 items-center justify-center rounded-full bg-[#ccff00]/15 text-[#ccff00]">
|
|
<Users className="h-5 w-5" />
|
|
</div>
|
|
<div className="min-w-0 flex-1">
|
|
<div className="flex items-center justify-between gap-2">
|
|
<h3 className="truncate font-bold text-white">{participants.length} people</h3>
|
|
<span className="shrink-0 text-xs text-gray-500">{timeAgo}</span>
|
|
</div>
|
|
<p className="mt-0.5 truncate text-xs text-gray-500">
|
|
{participants.slice(0, 3).map((p, i) => (
|
|
<span key={p}>
|
|
{i > 0 && ', '}
|
|
<GroupParticipantName pubkey={p} />
|
|
</span>
|
|
))}
|
|
{participants.length > 3 && ` +${participants.length - 3}`}
|
|
</p>
|
|
{last && (
|
|
<p className="truncate text-sm text-gray-500">
|
|
{isFromMe && <span className="text-gray-400">You: </span>}
|
|
{last.content}
|
|
</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
</Link>
|
|
);
|
|
}
|
|
|
|
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 { 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 (
|
|
<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>
|
|
}
|
|
/>
|
|
|
|
{/* Group conversations (NIP-17 group DMs) */}
|
|
{groups && groups.length > 0 && (
|
|
<div className="mb-5">
|
|
<h2 className="mb-2 flex items-center gap-1.5 text-xs font-bold uppercase tracking-widest text-gray-500">
|
|
<Users className="h-3.5 w-3.5" /> Groups
|
|
</h2>
|
|
<div className="space-y-3">
|
|
{groups.map((g) => (
|
|
<GroupPreview key={g.pubkey} conversation={g} />
|
|
))}
|
|
</div>
|
|
</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>
|
|
<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>
|
|
);
|
|
}
|