Files
Messenger/web/src/components/NewChatDialog.tsx
T
vantablackTea 61183d3f89
CI / Go (build + vet + test) (push) Canceled after 0s
CI / Web (build + lint) (push) Canceled after 0s
feat: webcore/old-web visual redesign
2026-09-28 13:02:58 +03:00

319 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useMemo, useRef, useState } from 'react'
import Avatar from './Avatar'
import { IconSearch, IconUserPlus, IconUsers } from './icons'
import { api } from '../lib/api'
import { userTitle } from '../lib/format'
import type { Contact, User } from '../lib/types'
type DialogMode = 'dm' | 'group'
interface NewChatDialogProps {
onClose: () => void
onStartChat: (user: User) => Promise<void>
onCreateGroup: (name: string, memberIds: number[]) => Promise<void>
onContactsChanged: () => void
}
export default function NewChatDialog({
onClose,
onStartChat,
onCreateGroup,
onContactsChanged,
}: NewChatDialogProps) {
const [mode, setMode] = useState<DialogMode>('dm')
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 [groupName, setGroupName] = useState('')
const [selectedIds, setSelectedIds] = useState<Set<number>>(() => new Set())
const [creating, setCreating] = useState(false)
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()
})
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
return (
<div className="fixed inset-0 z-50 flex items-end justify-center bg-black/50 p-0 sm:items-center sm:p-4">
<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="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)]"
>
<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">
×
</button>
</div>
<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">
{(['dm', 'group'] as const).map((value) => (
<button
key={value}
type="button"
onClick={() => setMode(value)}
className={`px-3 py-1.5 text-xs font-bold ${mode === value ? 'bevel-in bg-surface-2' : 'retro-btn'}`}
>
{value === 'dm' ? 'Личный чат' : 'Группа'}
</button>
))}
</div>
{mode === 'group' && (
<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" />
<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}
className="retro-input w-full py-1.5 pr-2 pl-7 text-xs"
/>
</div>
)}
<div className="relative">
<IconSearch className="pointer-events-none absolute top-1/2 left-2 h-3.5 w-3.5 -translate-y-1/2 text-surface-3" />
<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="retro-input w-full py-1.5 pr-2 pl-7 text-xs"
/>
</div>
{error && <p className="mt-1.5 text-[11px] text-danger">{error}</p>}
</div>
<div className="scrollbar-slim flex-1 overflow-y-auto px-1.5 py-2">
{(loadingContacts && trimmed.length === 0) || (searching && list.length === 0) ? (
<div className="space-y-1 p-1">
{[0, 1, 2].map((row) => (
<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" />
</div>
))}
</div>
) : list.length === 0 ? (
<div className="px-4 py-10 text-center text-xs text-muted">
{trimmed.length > 0 ? 'Пользователи не найдены' : 'Список контактов пуст'}
</div>
) : (
<ul>
{list.map((user) => {
const isContact = contactIds.has(user.id)
const busy = busyId === user.id
const selected = selectedIds.has(user.id)
return (
<li key={user.id}>
<div
className={`flex items-center gap-2 px-1.5 py-1.5 ${
mode === 'group' && selected ? 'bevel-in bg-accent-ghost' : 'hover:bg-surface-2'
}`}
>
{mode === 'group' && (
<button
type="button"
onClick={() => toggleMember(user.id)}
aria-pressed={selected}
aria-label={selected ? 'Убрать из группы' : 'Добавить в группу'}
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'
}`}
>
<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"
/>
</svg>
</button>
)}
<Avatar name={userTitle(user)} seed={user.id} size="md" />
<div className="min-w-0 flex-1">
<p className="truncate text-xs font-bold text-fg">{userTitle(user)}</p>
<p className="truncate font-mono text-[10px] text-muted">@{user.username}</p>
</div>
{mode === 'dm' && (
<>
{!isContact && (
<button
type="button"
onClick={() => void handleAdd(user)}
disabled={busy}
className="retro-btn inline-flex items-center gap-1 px-2 py-1 text-[11px] font-bold"
>
<IconUserPlus className="h-3.5 w-3.5" />
Добавить
</button>
)}
<button
type="button"
onClick={() => void handleStart(user)}
disabled={busy}
className="retro-btn-primary px-2.5 py-1 text-[11px] font-bold"
>
{busy ? '…' : 'Написать'}
</button>
</>
)}
</div>
</li>
)
})}
</ul>
)}
</div>
{mode === 'group' && (
<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>
</span>
<button
type="button"
onClick={() => void handleCreateGroup()}
disabled={!canCreate}
className="retro-btn-primary px-3 py-1.5 text-xs font-bold"
>
{creating ? 'Создание…' : 'Создать группу'}
</button>
</div>
)}
</div>
</div>
)
}