diff --git a/packages/web/src/components/modals/AddDmMemberModal.tsx b/packages/web/src/components/modals/AddDmMemberModal.tsx index d405175c..385117f4 100644 --- a/packages/web/src/components/modals/AddDmMemberModal.tsx +++ b/packages/web/src/components/modals/AddDmMemberModal.tsx @@ -1,18 +1,17 @@ -import React, { useState, useRef, useEffect } from 'react'; +import React, { useState, useRef, useEffect, useMemo } from 'react'; import { useNavigate } from 'react-router-dom'; import { Modal } from '../ui/Modal'; import { Avatar } from '../ui/Avatar'; import { useUIStore } from '../../stores/uiStore'; import { useSpaceStore } from '../../stores/spaceStore'; import { useAuthStore } from '../../stores/authStore'; +import { useSocialStore, type TaggedFriend } from '../../stores/socialStore'; import { api } from '../../api/client'; -import type { User } from '@backspace/shared'; import { isSelf } from '../../utils/identity'; export function AddDmMemberModal() { const [query, setQuery] = useState(''); - const [results, setResults] = useState([]); - const [isSearching, setIsSearching] = useState(false); + const [selected, setSelected] = useState>(new Set()); const [error, setError] = useState(''); const [isAdding, setIsAdding] = useState(false); const activeModal = useUIStore((s) => s.activeModal); @@ -20,110 +19,161 @@ export function AddDmMemberModal() { const closeModal = useUIStore((s) => s.closeModal); const dmChannels = useSpaceStore((s) => s.dmChannels); const addDmChannel = useSpaceStore((s) => s.addDmChannel); + const friends = useSocialStore((s) => s.friends); const navigate = useNavigate(); const myUser = useAuthStore((s) => s.user); const inputRef = useRef(null); - const searchTimer = useRef>(); const isOpen = activeModal === 'addDmMember'; const dmChannelId = modalData.dmChannelId as string | undefined; const dmChannel = dmChannels.find(dm => dm.id === dmChannelId); - const currentMemberIds = new Set(dmChannel?.members.map(m => m.id) ?? []); + const currentMemberIds = useMemo( + () => new Set(dmChannel?.members.map(m => m.id) ?? []), + [dmChannel?.members], + ); const memberCount = dmChannel?.members.length ?? 0; + const maxMembers = 10; + const remainingSlots = maxMembers - memberCount; + // Filter friends: client-side search, exclude self + const filteredFriends = useMemo(() => { + const q = query.trim().toLowerCase(); + return friends.filter((f) => { + if (isSelf(f, myUser)) return false; + if (!q) return true; + const displayName = (f.displayName ?? '').toLowerCase(); + const username = f.username.toLowerCase(); + return displayName.includes(q) || username.includes(q); + }); + }, [friends, query, myUser]); + + // Reset state when modal opens useEffect(() => { if (isOpen) { setQuery(''); - setResults([]); + setSelected(new Set()); setError(''); setIsAdding(false); setTimeout(() => inputRef.current?.focus(), 100); } }, [isOpen]); - const handleSearch = (value: string) => { - setQuery(value); - setError(''); - - if (searchTimer.current) { - clearTimeout(searchTimer.current); - } - - if (value.trim().length < 2) { - setResults([]); - return; - } - - searchTimer.current = setTimeout(async () => { - setIsSearching(true); - try { - const users = await api.social.search(value.trim()); - // Filter out users already in the DM - setResults(users.filter(u => !currentMemberIds.has(u.id))); - } catch { - setResults([]); - } finally { - setIsSearching(false); + const toggleFriend = (friendId: string) => { + if (currentMemberIds.has(friendId)) return; + setSelected((prev) => { + const next = new Set(prev); + if (next.has(friendId)) { + next.delete(friendId); + } else { + // Enforce remaining capacity + if (next.size >= remainingSlots) return prev; + next.add(friendId); } - }, 300); + return next; + }); }; - const handleSelectUser = async (user: User) => { - if (!dmChannelId || !dmChannel || isAdding) return; + const removeFriend = (friendId: string) => { + setSelected((prev) => { + const next = new Set(prev); + next.delete(friendId); + return next; + }); + }; + + const selectedFriends = useMemo( + () => friends.filter((f) => selected.has(f.id)), + [friends, selected], + ); + + const handleSubmit = async () => { + if (!dmChannelId || !dmChannel || isAdding || selectedFriends.length === 0) return; setError(''); setIsAdding(true); try { if (!dmChannel.ownerId) { - // 1-on-1 DM → create a new group DM with all 3 users + // 1-on-1 DM → create a new group DM with all selected + existing other member const otherMember = dmChannel.members.find(m => !isSelf(m, myUser)); if (!otherMember) { setError('Could not determine the other member of this conversation.'); + setIsAdding(false); return; } - const newChannel = await api.dm.createGroup({ - users: [ - { id: otherMember.id, homeUserId: otherMember.homeUserId, homeInstance: otherMember.homeInstance }, - { id: user.id, homeUserId: user.homeUserId, homeInstance: user.homeInstance }, - ], - }); + const users = [ + { id: otherMember.id, homeUserId: otherMember.homeUserId, homeInstance: otherMember.homeInstance }, + ...selectedFriends.map((f) => ({ + id: f.id, + homeUserId: f.homeUserId, + homeInstance: f.homeInstance, + })), + ]; + const newChannel = await api.dm.createGroup({ users }); addDmChannel(newChannel); closeModal(); navigate(`/channels/@me/${newChannel.id}`); } else { - // Group DM → add to existing group - await api.dm.addMember(dmChannelId, { userId: user.id }); + // Existing group DM → add each friend sequentially + for (const friend of selectedFriends) { + await api.dm.addMember(dmChannelId, { userId: friend.id }); + } closeModal(); } } catch (err) { - setError((err as Error).message || 'Failed to add member'); + setError((err as Error).message || 'Failed to add members'); } finally { setIsAdding(false); } }; + const buttonText = selectedFriends.length === 0 + ? 'Select Friends' + : `Add ${selectedFriends.length} Friend${selectedFriends.length > 1 ? 's' : ''}`; + return (
+ {/* Header with member count */}

- Search for a user to add to this conversation. + Select friends to add to this conversation.

- {memberCount}/10 + {memberCount}/{maxMembers}
+ {/* Selected chips */} + {selectedFriends.length > 0 && ( +
+ {selectedFriends.map((f) => ( + + {f.displayName ?? f.username} + + + ))} +
+ )} + + {/* Search input */} handleSearch(e.target.value)} - placeholder="Search for a user..." + onChange={(e) => setQuery(e.target.value)} + placeholder="Search friends..." className="input-search w-full py-2 text-[14px]" - disabled={memberCount >= 10} + disabled={remainingSlots <= 0} /> - {memberCount >= 10 && ( + {remainingSlots <= 0 && (

This group DM has reached the 10-member limit.

)} @@ -131,32 +181,81 @@ export function AddDmMemberModal() {

{error}

)} + {/* Friend list */}
- {isSearching && ( -
Searching...
+ {filteredFriends.length === 0 && ( +
+ {query.trim() ? 'No friends match your search' : 'No friends yet'} +
)} - {!isSearching && query.trim().length >= 2 && results.length === 0 && ( -
No users found
- )} + {filteredFriends.map((friend) => { + const isInDm = currentMemberIds.has(friend.id); + const isSelected = selected.has(friend.id); + const atCapacity = !isSelected && selected.size >= remainingSlots; - {results.map((user) => ( -
- - ))} + {!isInDm && ( +
+ {isSelected && ( + + + + )} +
+ )} + + ); + })}
+ + {/* Submit button */} +
);