~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).
307 lines
11 KiB
TypeScript
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>
|
|
);
|
|
}
|