frontend: now working fully, fuctionality tested
This commit is contained in:
@@ -0,0 +1,208 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user