SkateSpots/src/pages/DirectMessagesPage.tsx
SkateSpots 0b796440f1 feat(i18n): Spanish batch 2 — nav/chrome, events/shops/tours, crews/DMs, settings/auth, wallet/stash/newsletter/stats
~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).
2026-10-02 10:11:06 +07:00

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} />
</>
);
}