feat(dm): create group DMs from the New Message picker

The people picker is now multi-select: tap to toggle (checkmark), then
'Start group (n)' opens the encrypted group thread, or 'Message' for one person.
Selecting one person still behaves exactly as before.
This commit is contained in:
SkateSpots 2026-09-26 19:55:03 +07:00
parent 2a47e5c9c5
commit adc3a20094

View file

@ -18,7 +18,7 @@ import { useCrewmates } from '@/hooks/useCrewmates';
import { useFollowing } from '@/hooks/useFollowing'; import { useFollowing } from '@/hooks/useFollowing';
import { genUserName } from '@/lib/genUserName'; import { genUserName } from '@/lib/genUserName';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { MessageCircle, Search, Users, UserCheck, ArrowRight } from 'lucide-react'; import { MessageCircle, Search, Users, UserCheck, ArrowRight, CheckCircle2 } from 'lucide-react';
interface NewMessageDialogProps { interface NewMessageDialogProps {
open: boolean; open: boolean;
@ -38,10 +38,12 @@ function PersonRow({
candidate, candidate,
query, query,
onPick, onPick,
selected,
}: { }: {
candidate: Candidate; candidate: Candidate;
query: string; query: string;
onPick: (pubkey: string) => void; onPick: (pubkey: string) => void;
selected: boolean;
}) { }) {
const author = useAuthor(candidate.pubkey); const author = useAuthor(candidate.pubkey);
const metadata = author.data?.metadata; const metadata = author.data?.metadata;
@ -59,7 +61,10 @@ function PersonRow({
<button <button
type="button" type="button"
onClick={() => onPick(candidate.pubkey)} onClick={() => onPick(candidate.pubkey)}
className="flex w-full items-center gap-3 rounded-lg p-2.5 text-left transition-colors hover:bg-gray-800 active:scale-[0.99]" 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"> <Avatar className="h-10 w-10 border-2 border-gray-700 flex-shrink-0">
<AvatarImage src={metadata?.picture} alt={name} /> <AvatarImage src={metadata?.picture} alt={name} />
@ -73,7 +78,11 @@ function PersonRow({
<p className="text-xs text-gray-500 truncate">{candidate.hint}</p> <p className="text-xs text-gray-500 truncate">{candidate.hint}</p>
)} )}
</div> </div>
<ArrowRight className="h-4 w-4 text-gray-600 flex-shrink-0" /> {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> </button>
); );
} }
@ -91,6 +100,7 @@ export function NewMessageDialog({ open, onOpenChange }: NewMessageDialogProps)
const [query, setQuery] = useState(''); const [query, setQuery] = useState('');
const [rawError, setRawError] = useState(''); const [rawError, setRawError] = useState('');
const [selected, setSelected] = useState<string[]>([]);
// Merge crew-mates (with shared-crew hints) and followed accounts, de-duped. // Merge crew-mates (with shared-crew hints) and followed accounts, de-duped.
const candidates = useMemo<Candidate[]>(() => { const candidates = useMemo<Candidate[]>(() => {
@ -122,13 +132,35 @@ export function NewMessageDialog({ open, onOpenChange }: NewMessageDialogProps)
const crewCandidates = candidates.filter((c) => c.group === 'crew'); const crewCandidates = candidates.filter((c) => c.group === 'crew');
const followingCandidates = candidates.filter((c) => c.group === 'following'); const followingCandidates = candidates.filter((c) => c.group === 'following');
const goToConversation = (pubkey: string) => { const reset = () => {
onOpenChange(false); onOpenChange(false);
setQuery(''); setQuery('');
setRawError(''); setRawError('');
setSelected([]);
};
const goToConversation = (pubkey: string) => {
reset();
navigate(`/messages/${nip19.npubEncode(pubkey)}`); 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. // If the query looks like a raw key, offer to open it directly.
const trimmed = query.trim(); const trimmed = query.trim();
const looksLikeNpub = trimmed.startsWith('npub1'); const looksLikeNpub = trimmed.startsWith('npub1');
@ -163,7 +195,7 @@ export function NewMessageDialog({ open, onOpenChange }: NewMessageDialogProps)
New Message New Message
</DialogTitle> </DialogTitle>
<DialogDescription className="text-gray-400 text-sm"> <DialogDescription className="text-gray-400 text-sm">
Search your people by name, or paste an npub to message anyone. Search your people by name, or paste an npub to message anyone. Select two or more to start a group.
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
@ -224,7 +256,8 @@ export function NewMessageDialog({ open, onOpenChange }: NewMessageDialogProps)
key={c.pubkey} key={c.pubkey}
candidate={c} candidate={c}
query={query} query={query}
onPick={goToConversation} selected={selected.includes(c.pubkey)}
onPick={toggleSelected}
/> />
))} ))}
</div> </div>
@ -241,7 +274,8 @@ export function NewMessageDialog({ open, onOpenChange }: NewMessageDialogProps)
key={c.pubkey} key={c.pubkey}
candidate={c} candidate={c}
query={query} query={query}
onPick={goToConversation} selected={selected.includes(c.pubkey)}
onPick={toggleSelected}
/> />
))} ))}
</div> </div>
@ -251,6 +285,19 @@ export function NewMessageDialog({ open, onOpenChange }: NewMessageDialogProps)
)} )}
</ScrollArea> </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">{selected.length} selected</span>
<Button
onClick={startChat}
className="ml-auto h-10 bg-[#ff0040] font-bold text-white hover:bg-[#ff0040]/90"
>
{selected.length > 1 ? `Start group (${selected.length})` : 'Message'}
<ArrowRight className="ml-2 h-4 w-4" />
</Button>
</div>
)}
</div> </div>
</DialogContent> </DialogContent>
</Dialog> </Dialog>