~3,470 new keys (5,770 total, EN=ES), ~115 more files wired. - nav + chromeUi: header, More menu, burger menu, footer, map toolbar widgets (sort/trust/weather), spot-type label helper. - placesUi: events, shops, tours, food. socialUi: crews, DMs, notifications, collectives, crew chat/coordinator. settingsUi: settings, auth/signup, onboarding, profile edit, legal. walletUi: wallet/Cashu, stash, mints, app shop, newsletter, community stats, leaderboards, profile. - Fixed 738 usages where an agent dropped the socialUi. prefix (caught by check:i18n).
416 lines
16 KiB
TypeScript
416 lines
16 KiB
TypeScript
import { useState } from 'react';
|
|
import { Link } from 'react-router-dom';
|
|
import { useSeoMeta } from '@unhead/react';
|
|
import { nip19 } from 'nostr-tools';
|
|
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 { useTranslation } from '@/contexts/language';
|
|
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 { t } = useTranslation();
|
|
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">
|
|
{t('socialUi.dms.securityReminder')}
|
|
</p>
|
|
<p className="text-gray-300 text-xs leading-relaxed">
|
|
<strong className="text-white">{t('socialUi.dms.phishingStrong')}</strong>{' '}
|
|
{t('socialUi.dms.phishingRest')}
|
|
</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={t('socialUi.dms.dismissReminder')}
|
|
>
|
|
<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 { t } = useTranslation();
|
|
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">{t('socialUi.dms.peopleCount', { count: participants.length })}</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">{t('socialUi.dms.youPrefix')}</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 { t } = useTranslation();
|
|
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={t('socialUi.dms.unreadMessages')}
|
|
title={t('socialUi.dms.unreadMessages')}
|
|
/>
|
|
)}
|
|
</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">{t('socialUi.dms.youPrefix')}</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 { t } = useTranslation();
|
|
const { user } = useCurrentUser();
|
|
const { logins } = useNostrLogin();
|
|
const { data: inbox, isLoading, error, refetch } = useConversations();
|
|
const conversations = inbox?.conversations;
|
|
const undecryptable = inbox?.undecryptable ?? 0;
|
|
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: t('socialUi.dms.seoTitle'),
|
|
description: t('socialUi.dms.seoDescription'),
|
|
});
|
|
|
|
// Not logged in
|
|
if (!user) {
|
|
return (
|
|
<>
|
|
<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">{t('socialUi.dms.loginTitle')}</h2>
|
|
<p className="text-gray-400">
|
|
{t('socialUi.dms.loginDesc')}
|
|
</p>
|
|
<div className="mt-6 flex justify-center">
|
|
<LoginArea className="max-w-60" />
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
</>
|
|
);
|
|
}
|
|
|
|
// 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 (
|
|
<>
|
|
<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">{t('socialUi.dms.secureLoginTitle')}</h2>
|
|
<p className="text-gray-400 mb-4 text-sm sm:text-base">
|
|
{t('socialUi.dms.secureLoginDesc')}
|
|
</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]">{t('socialUi.dms.onAndroid')}</strong> {t('socialUi.dms.useAmber')}
|
|
</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"
|
|
>
|
|
{t('socialUi.dms.getAmber')}
|
|
</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">{t('socialUi.dms.onIOS')}</strong> {t('socialUi.dms.iosHint')}
|
|
</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
) : (
|
|
<p className="text-sm text-gray-500">
|
|
{t('socialUi.dms.browserHint')}
|
|
</p>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
</>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<div className="container px-4 py-4 sm:py-8 max-w-2xl">
|
|
{/* Phishing warning — shown once, dismissible */}
|
|
<PhishingWarningBanner />
|
|
|
|
<PageHeader
|
|
title={t('socialUi.dms.title')}
|
|
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">{t('socialUi.dms.subtitleFull')}</span>
|
|
<span className="xs:hidden">{t('socialUi.dms.subtitleShort')}</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">{t('socialUi.dms.newMessage')}</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" /> {t('socialUi.dms.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">{t('socialUi.dms.loadFailed')}</p>
|
|
<Button variant="outline" className="mt-4" onClick={() => refetch()}>
|
|
{t('common.tryAgain')}
|
|
</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">{t('socialUi.dms.emptyTitle')}</h2>
|
|
<p className="text-gray-400 mb-6">
|
|
{t('socialUi.dms.emptyDesc')}
|
|
</p>
|
|
<Button
|
|
onClick={() => setShowNewMessage(true)}
|
|
className="bg-[#ff0040] hover:bg-[#ff0040]/90 text-white font-bold"
|
|
>
|
|
<Plus className="h-4 w-4 mr-2" />
|
|
{t('socialUi.dms.startConversation')}
|
|
</Button>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
|
|
{/* Honest diagnostic: fetched-but-unreadable is NOT the same as an empty
|
|
inbox. Without this, a decrypt failure looks identical to "nothing
|
|
here" and is impossible to diagnose. */}
|
|
{undecryptable > 0 && (
|
|
<p className="pt-1 text-center text-xs text-gray-500">
|
|
{undecryptable === 1
|
|
? t('socialUi.dms.undecryptableOne', { count: undecryptable })
|
|
: t('socialUi.dms.undecryptableMany', { count: undecryptable })}
|
|
</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* New Message — searchable people picker */}
|
|
<NewMessageDialog open={showNewMessage} onOpenChange={setShowNewMessage} />
|
|
</>
|
|
);
|
|
}
|