2026-09-27 20:12:29 +03:00
|
|
|
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
|
|
|
|
|
import Avatar from './Avatar'
|
2026-09-28 13:02:58 +03:00
|
|
|
|
import { IconSearch, IconUserPlus, IconUsers } from './icons'
|
2026-09-27 20:12:29 +03:00
|
|
|
|
import { api } from '../lib/api'
|
|
|
|
|
|
import { userTitle } from '../lib/format'
|
|
|
|
|
|
import type { Contact, User } from '../lib/types'
|
|
|
|
|
|
|
2026-09-27 21:59:48 +03:00
|
|
|
|
type DialogMode = 'dm' | 'group'
|
|
|
|
|
|
|
2026-09-27 20:12:29 +03:00
|
|
|
|
interface NewChatDialogProps {
|
|
|
|
|
|
onClose: () => void
|
|
|
|
|
|
onStartChat: (user: User) => Promise<void>
|
2026-09-27 21:59:48 +03:00
|
|
|
|
onCreateGroup: (name: string, memberIds: number[]) => Promise<void>
|
2026-09-27 20:12:29 +03:00
|
|
|
|
onContactsChanged: () => void
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-27 21:59:48 +03:00
|
|
|
|
export default function NewChatDialog({
|
|
|
|
|
|
onClose,
|
|
|
|
|
|
onStartChat,
|
|
|
|
|
|
onCreateGroup,
|
|
|
|
|
|
onContactsChanged,
|
|
|
|
|
|
}: NewChatDialogProps) {
|
|
|
|
|
|
const [mode, setMode] = useState<DialogMode>('dm')
|
2026-09-27 20:12:29 +03:00
|
|
|
|
const [query, setQuery] = useState('')
|
|
|
|
|
|
const [results, setResults] = useState<User[]>([])
|
|
|
|
|
|
const [resultQuery, setResultQuery] = useState('')
|
|
|
|
|
|
const [contacts, setContacts] = useState<Contact[]>([])
|
|
|
|
|
|
const [loadingContacts, setLoadingContacts] = useState(true)
|
|
|
|
|
|
const [error, setError] = useState<string | null>(null)
|
|
|
|
|
|
const [busyId, setBusyId] = useState<number | null>(null)
|
2026-09-27 21:59:48 +03:00
|
|
|
|
const [groupName, setGroupName] = useState('')
|
|
|
|
|
|
const [selectedIds, setSelectedIds] = useState<Set<number>>(() => new Set())
|
|
|
|
|
|
const [creating, setCreating] = useState(false)
|
2026-09-27 20:12:29 +03:00
|
|
|
|
const inputRef = useRef<HTMLInputElement>(null)
|
|
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
inputRef.current?.focus()
|
|
|
|
|
|
const onKeyDown = (event: KeyboardEvent) => {
|
|
|
|
|
|
if (event.key === 'Escape') onClose()
|
|
|
|
|
|
}
|
|
|
|
|
|
window.addEventListener('keydown', onKeyDown)
|
|
|
|
|
|
return () => window.removeEventListener('keydown', onKeyDown)
|
|
|
|
|
|
}, [onClose])
|
|
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
let cancelled = false
|
|
|
|
|
|
api
|
|
|
|
|
|
.listContacts()
|
|
|
|
|
|
.then((list) => {
|
|
|
|
|
|
if (!cancelled) setContacts(list)
|
|
|
|
|
|
})
|
|
|
|
|
|
.catch(() => {
|
|
|
|
|
|
if (!cancelled) setContacts([])
|
|
|
|
|
|
})
|
|
|
|
|
|
.finally(() => {
|
|
|
|
|
|
if (!cancelled) setLoadingContacts(false)
|
|
|
|
|
|
})
|
|
|
|
|
|
return () => {
|
|
|
|
|
|
cancelled = true
|
|
|
|
|
|
}
|
|
|
|
|
|
}, [])
|
|
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
const term = query.trim()
|
|
|
|
|
|
if (term.length === 0) return
|
|
|
|
|
|
let cancelled = false
|
|
|
|
|
|
const timer = window.setTimeout(() => {
|
|
|
|
|
|
api
|
|
|
|
|
|
.searchUsers(term)
|
|
|
|
|
|
.then((users) => {
|
|
|
|
|
|
if (cancelled) return
|
|
|
|
|
|
setResults(users)
|
|
|
|
|
|
setResultQuery(term)
|
|
|
|
|
|
})
|
|
|
|
|
|
.catch(() => {
|
|
|
|
|
|
if (cancelled) return
|
|
|
|
|
|
setResults([])
|
|
|
|
|
|
setResultQuery(term)
|
|
|
|
|
|
})
|
|
|
|
|
|
}, 250)
|
|
|
|
|
|
return () => {
|
|
|
|
|
|
cancelled = true
|
|
|
|
|
|
window.clearTimeout(timer)
|
|
|
|
|
|
}
|
|
|
|
|
|
}, [query])
|
|
|
|
|
|
|
|
|
|
|
|
const contactIds = useMemo(() => new Set(contacts.map((contact) => contact.user.id)), [contacts])
|
|
|
|
|
|
const trimmed = query.trim()
|
|
|
|
|
|
const searching = trimmed.length > 0 && resultQuery !== trimmed
|
|
|
|
|
|
const list = trimmed.length > 0 ? results : contacts.map((contact) => contact.user)
|
|
|
|
|
|
|
|
|
|
|
|
const runAction = async (user: User, action: () => Promise<void>) => {
|
|
|
|
|
|
setBusyId(user.id)
|
|
|
|
|
|
setError(null)
|
|
|
|
|
|
try {
|
|
|
|
|
|
await action()
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
setError(err instanceof Error ? err.message : 'Не удалось выполнить действие')
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setBusyId(null)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const handleStart = (user: User) =>
|
|
|
|
|
|
runAction(user, async () => {
|
|
|
|
|
|
await onStartChat(user)
|
|
|
|
|
|
onClose()
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
const handleAdd = (user: User) =>
|
|
|
|
|
|
runAction(user, async () => {
|
|
|
|
|
|
await api.addContact(user.id)
|
|
|
|
|
|
setContacts((prev) =>
|
|
|
|
|
|
prev.some((contact) => contact.user.id === user.id)
|
|
|
|
|
|
? prev
|
|
|
|
|
|
: [...prev, { user, added_at: new Date().toISOString() }],
|
|
|
|
|
|
)
|
|
|
|
|
|
onContactsChanged()
|
|
|
|
|
|
})
|
|
|
|
|
|
|
2026-09-27 21:59:48 +03:00
|
|
|
|
const toggleMember = (userId: number) => {
|
|
|
|
|
|
setSelectedIds((prev) => {
|
|
|
|
|
|
const next = new Set(prev)
|
|
|
|
|
|
if (next.has(userId)) next.delete(userId)
|
|
|
|
|
|
else next.add(userId)
|
|
|
|
|
|
return next
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const handleCreateGroup = async () => {
|
|
|
|
|
|
const name = groupName.trim()
|
|
|
|
|
|
if (!name || selectedIds.size === 0) return
|
|
|
|
|
|
setCreating(true)
|
|
|
|
|
|
setError(null)
|
|
|
|
|
|
try {
|
|
|
|
|
|
await onCreateGroup(name, [...selectedIds])
|
|
|
|
|
|
onClose()
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
setError(err instanceof Error ? err.message : 'Не удалось создать группу')
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setCreating(false)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const canCreate = groupName.trim().length > 0 && selectedIds.size > 0 && !creating
|
|
|
|
|
|
|
2026-09-27 20:12:29 +03:00
|
|
|
|
return (
|
2026-09-28 13:02:58 +03:00
|
|
|
|
<div className="fixed inset-0 z-50 flex items-end justify-center bg-black/50 p-0 sm:items-center sm:p-4">
|
2026-09-27 20:12:29 +03:00
|
|
|
|
<button type="button" aria-label="Закрыть" onClick={onClose} className="absolute inset-0 cursor-default" />
|
|
|
|
|
|
<div
|
|
|
|
|
|
role="dialog"
|
|
|
|
|
|
aria-modal="true"
|
|
|
|
|
|
aria-labelledby="new-chat-title"
|
2026-09-28 13:02:58 +03:00
|
|
|
|
className="bevel relative flex max-h-[85vh] w-full max-w-md animate-rise flex-col bg-panel shadow-[4px_4px_0_rgba(0,0,0,0.4)]"
|
2026-09-27 20:12:29 +03:00
|
|
|
|
>
|
2026-09-28 13:02:58 +03:00
|
|
|
|
<div className="win-title flex shrink-0 items-center gap-1.5 px-1.5 py-1">
|
|
|
|
|
|
<span className="flex-1 truncate text-xs font-bold tracking-wide">Новый чат</span>
|
|
|
|
|
|
<button type="button" onClick={onClose} aria-label="Закрыть" className="win-btn">
|
|
|
|
|
|
×
|
2026-09-27 20:12:29 +03:00
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-09-28 13:02:58 +03:00
|
|
|
|
<div className="px-2 pt-2">
|
|
|
|
|
|
<p className="mb-2 text-[11px] text-muted">
|
|
|
|
|
|
{mode === 'dm' ? 'Найдите человека или выберите из контактов' : 'Соберите группу и добавьте участников'}
|
|
|
|
|
|
</p>
|
|
|
|
|
|
<div className="mb-2 grid grid-cols-2 gap-1">
|
2026-09-27 21:59:48 +03:00
|
|
|
|
{(['dm', 'group'] as const).map((value) => (
|
|
|
|
|
|
<button
|
|
|
|
|
|
key={value}
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
onClick={() => setMode(value)}
|
2026-09-28 13:02:58 +03:00
|
|
|
|
className={`px-3 py-1.5 text-xs font-bold ${mode === value ? 'bevel-in bg-surface-2' : 'retro-btn'}`}
|
2026-09-27 21:59:48 +03:00
|
|
|
|
>
|
|
|
|
|
|
{value === 'dm' ? 'Личный чат' : 'Группа'}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{mode === 'group' && (
|
2026-09-28 13:02:58 +03:00
|
|
|
|
<div className="relative mb-2">
|
|
|
|
|
|
<IconUsers className="pointer-events-none absolute top-1/2 left-2 h-3.5 w-3.5 -translate-y-1/2 text-surface-3" />
|
2026-09-27 21:59:48 +03:00
|
|
|
|
<label htmlFor="group-name" className="sr-only">
|
|
|
|
|
|
Название группы
|
|
|
|
|
|
</label>
|
|
|
|
|
|
<input
|
|
|
|
|
|
id="group-name"
|
|
|
|
|
|
type="text"
|
|
|
|
|
|
value={groupName}
|
|
|
|
|
|
onChange={(event) => setGroupName(event.target.value)}
|
|
|
|
|
|
placeholder="Название группы"
|
|
|
|
|
|
maxLength={80}
|
2026-09-28 13:02:58 +03:00
|
|
|
|
className="retro-input w-full py-1.5 pr-2 pl-7 text-xs"
|
2026-09-27 21:59:48 +03:00
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
2026-09-27 20:12:29 +03:00
|
|
|
|
<div className="relative">
|
2026-09-28 13:02:58 +03:00
|
|
|
|
<IconSearch className="pointer-events-none absolute top-1/2 left-2 h-3.5 w-3.5 -translate-y-1/2 text-surface-3" />
|
2026-09-27 20:12:29 +03:00
|
|
|
|
<label htmlFor="new-chat-search" className="sr-only">
|
|
|
|
|
|
Поиск пользователей
|
|
|
|
|
|
</label>
|
|
|
|
|
|
<input
|
|
|
|
|
|
id="new-chat-search"
|
|
|
|
|
|
ref={inputRef}
|
|
|
|
|
|
type="search"
|
|
|
|
|
|
value={query}
|
|
|
|
|
|
onChange={(event) => setQuery(event.target.value)}
|
|
|
|
|
|
placeholder="Имя или @username"
|
2026-09-28 13:02:58 +03:00
|
|
|
|
className="retro-input w-full py-1.5 pr-2 pl-7 text-xs"
|
2026-09-27 20:12:29 +03:00
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
2026-09-28 13:02:58 +03:00
|
|
|
|
{error && <p className="mt-1.5 text-[11px] text-danger">{error}</p>}
|
2026-09-27 20:12:29 +03:00
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-09-28 13:02:58 +03:00
|
|
|
|
<div className="scrollbar-slim flex-1 overflow-y-auto px-1.5 py-2">
|
2026-09-27 20:12:29 +03:00
|
|
|
|
{(loadingContacts && trimmed.length === 0) || (searching && list.length === 0) ? (
|
2026-09-28 13:02:58 +03:00
|
|
|
|
<div className="space-y-1 p-1">
|
2026-09-27 20:12:29 +03:00
|
|
|
|
{[0, 1, 2].map((row) => (
|
2026-09-28 13:02:58 +03:00
|
|
|
|
<div key={row} className="flex items-center gap-2 p-1.5">
|
|
|
|
|
|
<span className="h-9 w-9 animate-pulse-soft bg-surface-2" />
|
|
|
|
|
|
<span className="h-2.5 w-1/2 animate-pulse-soft bg-surface-2" />
|
2026-09-27 20:12:29 +03:00
|
|
|
|
</div>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
) : list.length === 0 ? (
|
2026-09-28 13:02:58 +03:00
|
|
|
|
<div className="px-4 py-10 text-center text-xs text-muted">
|
2026-09-27 20:12:29 +03:00
|
|
|
|
{trimmed.length > 0 ? 'Пользователи не найдены' : 'Список контактов пуст'}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
) : (
|
2026-09-28 13:02:58 +03:00
|
|
|
|
<ul>
|
2026-09-27 20:12:29 +03:00
|
|
|
|
{list.map((user) => {
|
|
|
|
|
|
const isContact = contactIds.has(user.id)
|
|
|
|
|
|
const busy = busyId === user.id
|
2026-09-27 21:59:48 +03:00
|
|
|
|
const selected = selectedIds.has(user.id)
|
2026-09-27 20:12:29 +03:00
|
|
|
|
return (
|
2026-09-27 21:59:48 +03:00
|
|
|
|
<li key={user.id}>
|
|
|
|
|
|
<div
|
2026-09-28 13:02:58 +03:00
|
|
|
|
className={`flex items-center gap-2 px-1.5 py-1.5 ${
|
|
|
|
|
|
mode === 'group' && selected ? 'bevel-in bg-accent-ghost' : 'hover:bg-surface-2'
|
2026-09-27 21:59:48 +03:00
|
|
|
|
}`}
|
2026-09-27 20:12:29 +03:00
|
|
|
|
>
|
2026-09-27 21:59:48 +03:00
|
|
|
|
{mode === 'group' && (
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
onClick={() => toggleMember(user.id)}
|
|
|
|
|
|
aria-pressed={selected}
|
|
|
|
|
|
aria-label={selected ? 'Убрать из группы' : 'Добавить в группу'}
|
2026-09-28 13:02:58 +03:00
|
|
|
|
className={`inline-flex h-4 w-4 shrink-0 items-center justify-center ${
|
|
|
|
|
|
selected ? 'bevel-in bg-accent text-white' : 'bevel-in bg-surface text-transparent'
|
2026-09-27 21:59:48 +03:00
|
|
|
|
}`}
|
|
|
|
|
|
>
|
2026-09-28 13:02:58 +03:00
|
|
|
|
<svg viewBox="0 0 24 24" className="h-3 w-3" fill="none" aria-hidden="true">
|
|
|
|
|
|
<path
|
|
|
|
|
|
d="m5 12.5 4.5 4.5L19 7"
|
|
|
|
|
|
stroke="currentColor"
|
|
|
|
|
|
strokeWidth="3"
|
|
|
|
|
|
strokeLinecap="square"
|
|
|
|
|
|
strokeLinejoin="miter"
|
|
|
|
|
|
/>
|
2026-09-27 21:59:48 +03:00
|
|
|
|
</svg>
|
|
|
|
|
|
</button>
|
|
|
|
|
|
)}
|
|
|
|
|
|
<Avatar name={userTitle(user)} seed={user.id} size="md" />
|
|
|
|
|
|
<div className="min-w-0 flex-1">
|
2026-09-28 13:02:58 +03:00
|
|
|
|
<p className="truncate text-xs font-bold text-fg">{userTitle(user)}</p>
|
|
|
|
|
|
<p className="truncate font-mono text-[10px] text-muted">@{user.username}</p>
|
2026-09-27 21:59:48 +03:00
|
|
|
|
</div>
|
|
|
|
|
|
{mode === 'dm' && (
|
|
|
|
|
|
<>
|
|
|
|
|
|
{!isContact && (
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
onClick={() => void handleAdd(user)}
|
|
|
|
|
|
disabled={busy}
|
2026-09-28 13:02:58 +03:00
|
|
|
|
className="retro-btn inline-flex items-center gap-1 px-2 py-1 text-[11px] font-bold"
|
2026-09-27 21:59:48 +03:00
|
|
|
|
>
|
2026-09-28 13:02:58 +03:00
|
|
|
|
<IconUserPlus className="h-3.5 w-3.5" />
|
2026-09-27 21:59:48 +03:00
|
|
|
|
Добавить
|
|
|
|
|
|
</button>
|
|
|
|
|
|
)}
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
onClick={() => void handleStart(user)}
|
|
|
|
|
|
disabled={busy}
|
2026-09-28 13:02:58 +03:00
|
|
|
|
className="retro-btn-primary px-2.5 py-1 text-[11px] font-bold"
|
2026-09-27 21:59:48 +03:00
|
|
|
|
>
|
|
|
|
|
|
{busy ? '…' : 'Написать'}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
2026-09-27 20:12:29 +03:00
|
|
|
|
</li>
|
|
|
|
|
|
)
|
|
|
|
|
|
})}
|
|
|
|
|
|
</ul>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
2026-09-27 21:59:48 +03:00
|
|
|
|
|
|
|
|
|
|
{mode === 'group' && (
|
2026-09-28 13:02:58 +03:00
|
|
|
|
<div className="flex items-center justify-between gap-2 border-t-2 border-surface-3 px-2 py-2">
|
|
|
|
|
|
<span className="text-[11px] text-muted">
|
|
|
|
|
|
Выбрано: <span className="font-mono font-bold text-fg">{selectedIds.size}</span>
|
2026-09-27 21:59:48 +03:00
|
|
|
|
</span>
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
onClick={() => void handleCreateGroup()}
|
|
|
|
|
|
disabled={!canCreate}
|
2026-09-28 13:02:58 +03:00
|
|
|
|
className="retro-btn-primary px-3 py-1.5 text-xs font-bold"
|
2026-09-27 21:59:48 +03:00
|
|
|
|
>
|
|
|
|
|
|
{creating ? 'Создание…' : 'Создать группу'}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
2026-09-27 20:12:29 +03:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|