SkateSpots/src/components/NewMessageDialog.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

307 lines
11 KiB
TypeScript

import { useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { nip19 } from 'nostr-tools';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
import { ScrollArea } from '@/components/ui/scroll-area';
import { useAuthor } from '@/hooks/useAuthor';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useCrewmates } from '@/hooks/useCrewmates';
import { useFollowing } from '@/hooks/useFollowing';
import { useTranslation } from '@/contexts/language';
import { genUserName } from '@/lib/genUserName';
import { cn } from '@/lib/utils';
import { MessageCircle, Search, Users, UserCheck, ArrowRight, CheckCircle2 } from 'lucide-react';
interface NewMessageDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
interface Candidate {
pubkey: string;
/** Short context line, e.g. shared crews or "Following". */
hint?: string;
/** Grouping bucket for section headers. */
group: 'crew' | 'following';
}
/** A single selectable person row. Resolves its own profile for name/avatar. */
function PersonRow({
candidate,
query,
onPick,
selected,
}: {
candidate: Candidate;
query: string;
onPick: (pubkey: string) => void;
selected: boolean;
}) {
const author = useAuthor(candidate.pubkey);
const metadata = author.data?.metadata;
const name = metadata?.name ?? genUserName(candidate.pubkey);
const nip05 = metadata?.nip05;
// Name-based filtering happens here (names are resolved async per row).
const q = query.trim().toLowerCase();
if (q) {
const haystack = `${name} ${nip05 ?? ''} ${candidate.hint ?? ''}`.toLowerCase();
if (!haystack.includes(q)) return null;
}
return (
<button
type="button"
onClick={() => onPick(candidate.pubkey)}
className={cn(
'flex w-full items-center gap-3 rounded-lg p-2.5 text-left transition-colors hover:bg-gray-800 active:scale-[0.99]',
selected && 'bg-[#ccff00]/10',
)}
>
<Avatar className="h-10 w-10 border-2 border-gray-700 flex-shrink-0">
<AvatarImage src={metadata?.picture} alt={name} />
<AvatarFallback className="bg-[#ff0040]/20 text-[#ff0040] text-xs">
{name.slice(0, 2).toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="flex-1 min-w-0">
<p className="font-bold text-white text-sm truncate">{name}</p>
{candidate.hint && (
<p className="text-xs text-gray-500 truncate">{candidate.hint}</p>
)}
</div>
{selected ? (
<CheckCircle2 className="h-5 w-5 flex-shrink-0 text-[#ccff00]" />
) : (
<ArrowRight className="h-4 w-4 flex-shrink-0 text-gray-600" />
)}
</button>
);
}
/**
* A best-in-class "New Message" picker: search your crew-mates and the people
* you follow by name, or paste a raw npub / hex key. Replaces the old
* paste-only flow so starting a DM feels like a real contacts screen.
*/
export function NewMessageDialog({ open, onOpenChange }: NewMessageDialogProps) {
const { t } = useTranslation();
const navigate = useNavigate();
const { user } = useCurrentUser();
const { crewmates, isLoading: crewmatesLoading } = useCrewmates();
const { data: following, isLoading: followingLoading } = useFollowing(user?.pubkey);
const [query, setQuery] = useState('');
const [rawError, setRawError] = useState('');
const [selected, setSelected] = useState<string[]>([]);
// Merge crew-mates (with shared-crew hints) and followed accounts, de-duped.
const candidates = useMemo<Candidate[]>(() => {
const seen = new Set<string>();
const list: Candidate[] = [];
for (const cm of crewmates) {
if (seen.has(cm.pubkey)) continue;
seen.add(cm.pubkey);
list.push({
pubkey: cm.pubkey,
group: 'crew',
hint:
cm.sharedCrews.length > 0
? t('socialUi.newMessage.inCrew', { crews: cm.sharedCrews.join(', ') })
: t('socialUi.newMessage.crewmate'),
});
}
for (const pk of following ?? []) {
if (pk === user?.pubkey || seen.has(pk)) continue;
seen.add(pk);
list.push({ pubkey: pk, group: 'following', hint: t('socialUi.newMessage.following') });
}
return list;
}, [crewmates, following, user?.pubkey, t]);
const crewCandidates = candidates.filter((c) => c.group === 'crew');
const followingCandidates = candidates.filter((c) => c.group === 'following');
const reset = () => {
onOpenChange(false);
setQuery('');
setRawError('');
setSelected([]);
};
const goToConversation = (pubkey: string) => {
reset();
navigate(`/messages/${nip19.npubEncode(pubkey)}`);
};
/** Add/remove a person from the group selection. */
const toggleSelected = (pubkey: string) =>
setSelected((prev) =>
prev.includes(pubkey) ? prev.filter((p) => p !== pubkey) : [...prev, pubkey],
);
/** One person → 1:1 thread; two or more → an encrypted group thread. */
const startChat = () => {
if (selected.length === 0) return;
const to =
selected.length === 1
? `/messages/${nip19.npubEncode(selected[0])}`
: `/messages/group/${selected.join(',')}`;
reset();
navigate(to);
};
// If the query looks like a raw key, offer to open it directly.
const trimmed = query.trim();
const looksLikeNpub = trimmed.startsWith('npub1');
const looksLikeHex = /^[a-f0-9]{64}$/i.test(trimmed);
const handleOpenRaw = () => {
setRawError('');
try {
let pubkey = trimmed;
if (looksLikeNpub) {
const decoded = nip19.decode(trimmed);
if (decoded.type !== 'npub') throw new Error('Invalid npub');
pubkey = decoded.data;
} else if (!looksLikeHex) {
throw new Error('Invalid key');
}
goToConversation(pubkey);
} catch {
setRawError(t('socialUi.newMessage.invalidKey'));
}
};
const isLoading = crewmatesLoading || followingLoading;
const hasAnyCandidates = candidates.length > 0;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<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]" />
{t('socialUi.dms.newMessage')}
</DialogTitle>
<DialogDescription className="text-gray-400 text-sm">
{t('socialUi.newMessage.description')}
</DialogDescription>
</DialogHeader>
<div className="mt-2 space-y-3">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-500" />
<Input
value={query}
onChange={(e) => {
setQuery(e.target.value);
setRawError('');
}}
placeholder={t('socialUi.newMessage.searchPlaceholder')}
className="bg-gray-900 border-gray-700 text-white pl-9 h-11"
autoFocus
/>
</div>
{/* Direct-open affordance when a raw key is pasted */}
{(looksLikeNpub || looksLikeHex) && (
<Button
onClick={handleOpenRaw}
className="w-full bg-[#ff0040] hover:bg-[#ff0040]/90 text-white font-bold h-11"
>
{t('socialUi.newMessage.messageThisKey')}
<ArrowRight className="h-4 w-4 ml-2" />
</Button>
)}
{rawError && <p className="text-sm text-red-500">{rawError}</p>}
{/* People list */}
{!looksLikeNpub && !looksLikeHex && (
<ScrollArea className="h-[min(50vh,360px)] pr-2">
{isLoading ? (
<div className="py-10 text-center text-sm text-gray-500">
{t('socialUi.newMessage.loadingPeople')}
</div>
) : !hasAnyCandidates ? (
<div className="py-10 text-center px-4">
<Users className="h-10 w-10 mx-auto mb-3 text-gray-600" />
<p className="text-sm text-gray-400">
{t('socialUi.newMessage.noPeople')}
</p>
<p className="text-xs text-gray-600 mt-1">
{t('socialUi.newMessage.noPeopleHint')}
</p>
</div>
) : (
<div className="space-y-4">
{crewCandidates.length > 0 && (
<div>
<p className="flex items-center gap-1.5 px-1 pb-1.5 text-[11px] font-bold uppercase tracking-wide text-teal-400">
<Users className="h-3.5 w-3.5" /> {t('socialUi.newMessage.yourCrews')}
</p>
<div className="space-y-1">
{crewCandidates.map((c) => (
<PersonRow
key={c.pubkey}
candidate={c}
query={query}
selected={selected.includes(c.pubkey)}
onPick={toggleSelected}
/>
))}
</div>
</div>
)}
{followingCandidates.length > 0 && (
<div>
<p className="flex items-center gap-1.5 px-1 pb-1.5 text-[11px] font-bold uppercase tracking-wide text-gray-400">
<UserCheck className="h-3.5 w-3.5" /> {t('socialUi.newMessage.following')}
</p>
<div className="space-y-1">
{followingCandidates.map((c) => (
<PersonRow
key={c.pubkey}
candidate={c}
query={query}
selected={selected.includes(c.pubkey)}
onPick={toggleSelected}
/>
))}
</div>
</div>
)}
</div>
)}
</ScrollArea>
)}
{selected.length > 0 && (
<div className="flex items-center gap-2 border-t border-white/5 pt-3">
<span className="text-xs text-gray-400">{t('socialUi.newMessage.selected', { count: selected.length })}</span>
<Button
onClick={startChat}
className="ml-auto h-10 bg-[#ff0040] font-bold text-white hover:bg-[#ff0040]/90"
>
{selected.length > 1 ? t('socialUi.newMessage.startGroup', { count: selected.length }) : t('socialUi.newMessage.message')}
<ArrowRight className="ml-2 h-4 w-4" />
</Button>
</div>
)}
</div>
</DialogContent>
</Dialog>
);
}