Files
Messenger/web/src/components/NewChatDialog.tsx
T

209 lines
8.0 KiB
TypeScript
Raw Normal View History

import { useEffect, useMemo, useRef, useState } from 'react'
import Avatar from './Avatar'
import { IconClose, IconSearch, IconUserPlus } from './icons'
import { api } from '../lib/api'
import { userTitle } from '../lib/format'
import type { Contact, User } from '../lib/types'
interface NewChatDialogProps {
onClose: () => void
onStartChat: (user: User) => Promise<void>
onContactsChanged: () => void
}
export default function NewChatDialog({ onClose, onStartChat, onContactsChanged }: NewChatDialogProps) {
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)
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()
})
return (
<div className="fixed inset-0 z-50 flex items-end justify-center bg-black/60 p-0 backdrop-blur-sm sm:items-center sm:p-6">
<button type="button" aria-label="Закрыть" onClick={onClose} className="absolute inset-0 cursor-default" />
<div
role="dialog"
aria-modal="true"
aria-labelledby="new-chat-title"
className="relative flex max-h-[85vh] w-full max-w-md animate-rise flex-col overflow-hidden rounded-t-3xl border border-line bg-panel shadow-2xl sm:rounded-3xl"
>
<div className="flex items-center justify-between border-b border-line/80 px-5 py-4">
<div>
<h2 id="new-chat-title" className="font-display text-base font-semibold text-fg">
Новый чат
</h2>
<p className="text-xs text-muted">Найдите человека или выберите из контактов</p>
</div>
<button
type="button"
onClick={onClose}
aria-label="Закрыть"
className="inline-flex h-9 w-9 items-center justify-center rounded-xl text-muted transition hover:bg-surface-2 hover:text-fg"
>
<IconClose className="h-5 w-5" />
</button>
</div>
<div className="px-5 pt-4">
<div className="relative">
<IconSearch className="pointer-events-none absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 text-faint" />
<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"
className="w-full rounded-xl border border-line bg-surface/70 py-2.5 pr-3 pl-9 text-sm text-fg placeholder:text-faint focus:border-accent/50 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/20"
/>
</div>
{error && <p className="mt-2 text-xs text-danger">{error}</p>}
</div>
<div className="scrollbar-slim flex-1 overflow-y-auto px-3 py-3">
{(loadingContacts && trimmed.length === 0) || (searching && list.length === 0) ? (
<div className="space-y-1.5 p-1">
{[0, 1, 2].map((row) => (
<div key={row} className="flex items-center gap-3 rounded-xl p-2.5">
<span className="h-10 w-10 animate-pulse-soft rounded-full bg-surface-2" />
<span className="h-3 w-1/2 animate-pulse-soft rounded-full bg-surface-2" />
</div>
))}
</div>
) : list.length === 0 ? (
<div className="px-6 py-12 text-center text-sm text-muted">
{trimmed.length > 0 ? 'Пользователи не найдены' : 'Список контактов пуст'}
</div>
) : (
<ul className="space-y-0.5">
{list.map((user) => {
const isContact = contactIds.has(user.id)
const busy = busyId === user.id
return (
<li key={user.id} className="flex items-center gap-3 rounded-xl px-2.5 py-2 transition hover:bg-surface-2/70">
<Avatar name={userTitle(user)} seed={user.id} size="md" />
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-semibold text-fg">{userTitle(user)}</p>
<p className="truncate text-xs text-muted">@{user.username}</p>
</div>
{!isContact && (
<button
type="button"
onClick={() => void handleAdd(user)}
disabled={busy}
className="inline-flex items-center gap-1 rounded-lg border border-line px-2.5 py-1.5 text-xs font-medium text-muted transition hover:border-accent/50 hover:text-accent disabled:opacity-50"
>
<IconUserPlus className="h-4 w-4" />
Добавить
</button>
)}
<button
type="button"
onClick={() => void handleStart(user)}
disabled={busy}
className="rounded-lg bg-gradient-to-br from-accent to-accent-strong px-3 py-1.5 text-xs font-semibold text-white transition hover:brightness-110 disabled:opacity-50"
>
{busy ? '…' : 'Написать'}
</button>
</li>
)
})}
</ul>
)}
</div>
</div>
</div>
)
}