frontend: now working fully, fuctionality tested
CI / Go (build + vet + test) (push) Canceled after 0s
CI / Web (build + lint) (push) Canceled after 0s

This commit is contained in:
vantablackTea
2026-09-27 20:12:29 +03:00
parent 1f3a0e4351
commit ca0aa9ee9c
15 changed files with 1657 additions and 74 deletions
+28 -42
View File
@@ -1,50 +1,36 @@
import { useEffect, useState } from 'react' import { useAuth } from './auth/AuthContext'
import { pingApi, type PingResponse } from './api' import AuthScreen from './screens/AuthScreen'
import './index.css' import ChatApp from './screens/ChatApp'
type ApiState =
| { status: 'loading' }
| { status: 'ok'; data: PingResponse }
| { status: 'error'; error: string }
function App() {
const [api, setApi] = useState<ApiState>({ status: 'loading' })
useEffect(() => {
let cancelled = false
pingApi()
.then((data) => {
if (!cancelled) setApi({ status: 'ok', data })
})
.catch((err: unknown) => {
if (!cancelled) setApi({ status: 'error', error: String(err) })
})
return () => {
cancelled = true
}
}, [])
function Splash() {
return ( return (
<main className="shell"> <div className="flex min-h-full flex-col items-center justify-center gap-5">
<h1>Messenger</h1> <div className="relative flex h-20 w-20 items-center justify-center">
<div className="card"> <span className="absolute inset-0 animate-ping rounded-3xl bg-accent/20" aria-hidden="true" />
<div className="status-row"> <span className="relative flex h-16 w-16 items-center justify-center rounded-3xl bg-gradient-to-br from-accent to-accent-strong text-white shadow-2xl shadow-accent/30">
<span className={`dot ${api.status}`} aria-hidden /> <svg viewBox="0 0 24 24" className="h-8 w-8" fill="none" aria-hidden="true">
<span className="status-label"> <path
{api.status === 'loading' && 'API: connecting…'} d="M4 12.5h3l2-5 3.2 9 2.3-6 1.5 2H20"
{api.status === 'ok' && 'API: connected'} stroke="currentColor"
{api.status === 'error' && 'API: unreachable'} strokeWidth="1.9"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</span> </span>
</div> </div>
{api.status === 'ok' && ( <div className="text-center">
<p className="detail"> <p className="font-display text-xl font-bold tracking-tight text-fg">Пульс</p>
{api.data.message} · server time {api.data.time} <p className="mt-1 text-sm text-muted">Подключаемся…</p>
</p> </div>
)}
{api.status === 'error' && <p className="detail">{api.error}</p>}
</div> </div>
</main>
) )
} }
export default App export default function App() {
const { user, loading } = useAuth()
if (loading) return <Splash />
if (!user) return <AuthScreen />
return <ChatApp user={user} />
}
-12
View File
@@ -1,12 +0,0 @@
export interface PingResponse {
message: string
time: string
}
export async function pingApi(): Promise<PingResponse> {
const res = await fetch('/api/v1/ping')
if (!res.ok) {
throw new Error(`API responded with ${res.status}`)
}
return res.json()
}
+2 -1
View File
@@ -71,7 +71,8 @@ export function AuthProvider({ children }: { children: ReactNode }) {
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider> return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>
} }
export function useAuth(): AuthContextValue { // oxlint-disable-next-line react/only-export-components -- provider and its hook must stay colocated
export const useAuth = (): AuthContextValue => {
const context = useContext(AuthContext) const context = useContext(AuthContext)
if (!context) throw new Error('useAuth должен использоваться внутри AuthProvider') if (!context) throw new Error('useAuth должен использоваться внутри AuthProvider')
return context return context
+40
View File
@@ -0,0 +1,40 @@
import { avatarColor, initials } from '../lib/format'
type AvatarSize = 'sm' | 'md' | 'lg' | 'xl'
interface AvatarProps {
name: string
seed: number | string
size?: AvatarSize
online?: boolean
className?: string
}
const SIZE_CLASSES: Record<AvatarSize, string> = {
sm: 'h-8 w-8 text-[11px]',
md: 'h-10 w-10 text-xs',
lg: 'h-12 w-12 text-sm',
xl: 'h-16 w-16 text-lg',
}
export default function Avatar({ name, seed, size = 'md', online = false, className = '' }: AvatarProps) {
const background = avatarColor(seed)
return (
<span className={`relative inline-flex shrink-0 ${className}`}>
<span
className={`inline-flex items-center justify-center rounded-full font-semibold text-white/95 shadow-inner ring-1 ring-white/10 ${SIZE_CLASSES[size]}`}
style={{ backgroundImage: `linear-gradient(145deg, ${background}, color-mix(in srgb, ${background} 62%, #000))` }}
aria-hidden="true"
>
{initials({ display_name: name, username: name })}
</span>
{online && (
<span
className="absolute -right-0.5 -bottom-0.5 rounded-full border-2 border-panel bg-online"
style={{ width: '30%', height: '30%', minWidth: 9, minHeight: 9 }}
aria-hidden="true"
/>
)}
</span>
)
}
+229
View File
@@ -0,0 +1,229 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import Avatar from './Avatar'
import MessageList from './MessageList'
import { IconArrowLeft, IconChat, IconSend } from './icons'
import { peerForConversation, userTitle } from '../lib/format'
import type { ClientMessage, Conversation, User } from '../lib/types'
interface ChatWindowProps {
className?: string
conversation: Conversation | null
currentUser: User
directory: Map<number, User>
peers: Record<number, number>
presence: Record<number, boolean>
peerIsTyping: boolean
messages: ClientMessage[]
loading: boolean
loadingOlder: boolean
hasMore: boolean
error: string | null
onBack: () => void
onSend: (conversationId: number, body: string) => void
onTyping: (conversationId: number, isTyping: boolean) => void
onLoadOlder: () => void
onRetry: (message: ClientMessage) => void
}
function TypingDots() {
return (
<span className="inline-flex items-center gap-0.5 align-middle" aria-hidden="true">
{[0, 1, 2].map((dot) => (
<span
key={dot}
className="h-1.5 w-1.5 animate-bounce-dot rounded-full bg-accent"
style={{ animationDelay: `${dot * 140}ms` }}
/>
))}
</span>
)
}
interface ComposerProps {
onSend: (body: string) => void
onTyping: (isTyping: boolean) => void
}
function Composer({ onSend, onTyping }: ComposerProps) {
const [value, setValue] = useState('')
const textareaRef = useRef<HTMLTextAreaElement>(null)
const typingRef = useRef(false)
const typingTimerRef = useRef<number | null>(null)
const stopTyping = useCallback(() => {
if (typingTimerRef.current !== null) {
window.clearTimeout(typingTimerRef.current)
typingTimerRef.current = null
}
if (typingRef.current) {
typingRef.current = false
onTyping(false)
}
}, [onTyping])
useEffect(() => stopTyping, [stopTyping])
const resize = useCallback(() => {
const el = textareaRef.current
if (!el) return
el.style.height = 'auto'
el.style.height = `${Math.min(el.scrollHeight, 168)}px`
}, [])
const submit = useCallback(() => {
const trimmed = value.trim()
if (!trimmed) return
onSend(trimmed)
setValue('')
stopTyping()
requestAnimationFrame(() => {
resize()
textareaRef.current?.focus()
})
}, [onSend, resize, stopTyping, value])
const handleChange = (next: string) => {
setValue(next)
resize()
if (!typingRef.current) {
typingRef.current = true
onTyping(true)
}
if (typingTimerRef.current !== null) window.clearTimeout(typingTimerRef.current)
typingTimerRef.current = window.setTimeout(() => {
typingRef.current = false
onTyping(false)
}, 2000)
}
return (
<div className="border-t border-line/80 bg-panel/70 px-3 py-3 backdrop-blur sm:px-5">
<div className="flex items-end gap-2 rounded-2xl border border-line bg-surface/80 p-1.5 transition focus-within:border-accent/60 focus-within:ring-2 focus-within:ring-accent/20">
<label htmlFor="composer" className="sr-only">
Сообщение
</label>
<textarea
id="composer"
ref={textareaRef}
value={value}
rows={1}
onChange={(event) => handleChange(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter' && !event.shiftKey && !event.nativeEvent.isComposing) {
event.preventDefault()
submit()
}
}}
placeholder="Написать сообщение…"
className="scrollbar-slim max-h-42 flex-1 resize-none bg-transparent px-2.5 py-2 text-[0.9375rem] text-fg placeholder:text-faint focus:outline-none"
/>
<button
type="button"
onClick={submit}
disabled={!value.trim()}
aria-label="Отправить сообщение"
className="mb-0.5 inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-xl bg-gradient-to-br from-accent to-accent-strong text-white shadow-lg shadow-accent/20 transition enabled:hover:brightness-110 enabled:active:scale-95 disabled:cursor-not-allowed disabled:opacity-40"
>
<IconSend className="h-4.5 w-4.5" />
</button>
</div>
<p className="mt-1.5 hidden px-1 text-[11px] text-faint sm:block">
Enter — отправить · Shift + Enter — новая строка
</p>
</div>
)
}
export default function ChatWindow({
className = '',
conversation,
currentUser,
directory,
peers,
presence,
peerIsTyping,
messages,
loading,
loadingOlder,
hasMore,
error,
onBack,
onSend,
onTyping,
onLoadOlder,
onRetry,
}: ChatWindowProps) {
if (!conversation) {
return (
<section className={`${className} flex-1 flex-col items-center justify-center gap-4 bg-panel/40 px-6 text-center`}>
<div className="flex h-16 w-16 items-center justify-center rounded-3xl border border-line bg-surface-2 text-accent">
<IconChat className="h-8 w-8" />
</div>
<div className="space-y-1">
<h2 className="font-display text-lg font-semibold text-fg">Выберите чат</h2>
<p className="max-w-xs text-sm text-muted">
Откройте диалог слева или начните новый — история и статусы появятся здесь.
</p>
</div>
</section>
)
}
const peer = peerForConversation(conversation, peers, directory, currentUser.id)
const peerName = peer ? userTitle(peer) : `Чат #${conversation.id}`
const isOnline = peer ? presence[peer.id] === true : false
let subtitle = 'нет данных о статусе'
if (peerIsTyping) subtitle = 'печатает…'
else if (isOnline) subtitle = 'в сети'
else if (peer) subtitle = 'не в сети'
return (
<section className={`${className} min-w-0 flex-1 flex-col bg-panel/40`}>
<header className="flex items-center gap-3 border-b border-line/80 bg-panel/70 px-3 py-2.5 backdrop-blur sm:px-5 sm:py-3">
<button
type="button"
onClick={onBack}
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 md:hidden"
>
<IconArrowLeft className="h-5 w-5" />
</button>
{peer ? (
<Avatar name={peerName} seed={peer.id} size="md" online={isOnline} />
) : (
<span className="flex h-10 w-10 items-center justify-center rounded-full bg-surface-2 font-semibold text-muted">
#
</span>
)}
<div className="min-w-0 flex-1">
<h2 className="truncate font-display text-[0.9375rem] font-semibold text-fg">{peerName}</h2>
<p className="flex items-center gap-1.5 truncate text-xs text-muted">
{peerIsTyping && <TypingDots />}
<span className={peerIsTyping ? 'text-accent' : ''}>{subtitle}</span>
</p>
</div>
</header>
<MessageList
messages={messages}
currentUser={currentUser}
directory={directory}
loading={loading}
loadingOlder={loadingOlder}
hasMore={hasMore}
error={error}
onLoadOlder={onLoadOlder}
onRetry={onRetry}
/>
<Composer
key={conversation.id}
onSend={(body) => onSend(conversation.id, body)}
onTyping={(isTyping) => onTyping(conversation.id, isTyping)}
/>
</section>
)
}
+83
View File
@@ -0,0 +1,83 @@
import Avatar from './Avatar'
import { IconAlert, IconCheck, IconCheckCheck } from './icons'
import { formatTime, userTitle } from '../lib/format'
import type { ClientMessage, User } from '../lib/types'
interface MessageBubbleProps {
message: ClientMessage
isOwn: boolean
sender: User | null
showAvatar: boolean
showName: boolean
onRetry: (message: ClientMessage) => void
}
export default function MessageBubble({ message, isOwn, sender, showAvatar, showName, onRetry }: MessageBubbleProps) {
const name = sender ? userTitle(sender) : `Пользователь #${message.sender_id}`
const isSending = message.status === 'sending'
const isError = message.status === 'error'
return (
<div
className={`flex w-full animate-msg-in items-end gap-2.5 px-3 sm:px-5 ${isOwn ? 'justify-end' : 'justify-start'}`}
>
{!isOwn &&
(showAvatar ? (
<Avatar name={name} seed={message.sender_id} size="sm" className="mb-0.5" />
) : (
<span className="w-8 shrink-0" aria-hidden="true" />
))}
<div className={`flex max-w-[82%] flex-col sm:max-w-[68%] ${isOwn ? 'items-end' : 'items-start'}`}>
{!isOwn && showName && (
<span className="mb-1 px-1 text-xs font-semibold text-muted">{name}</span>
)}
<div
className={[
'relative rounded-2xl px-3.5 py-2 text-[0.9375rem] leading-relaxed shadow-sm',
isOwn
? 'rounded-br-md bg-gradient-to-br from-accent to-accent-strong text-white'
: 'rounded-bl-md border border-line/70 bg-surface-2 text-fg',
isError ? 'ring-1 ring-danger/60' : '',
isSending ? 'opacity-80' : '',
].join(' ')}
>
<p className="whitespace-pre-wrap break-words text-balance">{message.body}</p>
<div
className={`mt-1 flex items-center justify-end gap-1 text-[10.5px] ${
isOwn ? 'text-white/70' : 'text-faint'
}`}
>
{message.edited_at && <span className="mr-auto pl-0.5">изменено</span>}
<time dateTime={message.created_at}>{formatTime(message.created_at)}</time>
{isOwn && (
<span className="inline-flex items-center" aria-hidden="true">
{isSending && (
<span className="h-3 w-3 animate-spin rounded-full border border-white/40 border-t-white/90" />
)}
{isError && (
<button
type="button"
onClick={() => onRetry(message)}
className="pointer-events-auto inline-flex items-center gap-1 rounded-full text-danger transition hover:text-red-300"
aria-label="Повторить отправку"
title="Не отправлено — нажмите, чтобы повторить"
>
<IconAlert className="h-3.5 w-3.5" />
</button>
)}
{!isSending && !isError && message.read_by_peer && <IconCheckCheck className="h-3.5 w-3.5" />}
{!isSending && !isError && !message.read_by_peer && <IconCheck className="h-3.5 w-3.5" />}
</span>
)}
</div>
</div>
{isError && <span className="mt-1 px-1 text-[11px] text-danger">Не отправлено</span>}
</div>
</div>
)
}
+146
View File
@@ -0,0 +1,146 @@
import { useCallback, useLayoutEffect, useRef } from 'react'
import MessageBubble from './MessageBubble'
import { formatDayDivider, isSameDay, minutesBetween } from '../lib/format'
import type { ClientMessage, User } from '../lib/types'
interface MessageListProps {
messages: ClientMessage[]
currentUser: User
directory: Map<number, User>
loading: boolean
loadingOlder: boolean
hasMore: boolean
error: string | null
onLoadOlder: () => void
onRetry: (message: ClientMessage) => void
}
const GROUP_WINDOW_MINUTES = 5
export default function MessageList({
messages,
currentUser,
directory,
loading,
loadingOlder,
hasMore,
error,
onLoadOlder,
onRetry,
}: MessageListProps) {
const scrollRef = useRef<HTMLDivElement>(null)
const firstIdRef = useRef<number | null>(null)
const prevScrollHeightRef = useRef(0)
const stickRef = useRef(true)
const handleScroll = useCallback(() => {
const el = scrollRef.current
if (!el) return
prevScrollHeightRef.current = el.scrollHeight
const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight
stickRef.current = distanceFromBottom < 140
if (el.scrollTop < 60 && hasMore && !loadingOlder) onLoadOlder()
}, [hasMore, loadingOlder, onLoadOlder])
useLayoutEffect(() => {
const el = scrollRef.current
if (!el) return
const firstId = messages.length > 0 ? messages[0].id : null
const prevFirst = firstIdRef.current
firstIdRef.current = firstId
if (prevFirst === null) {
el.scrollTop = el.scrollHeight
return
}
if (firstId !== null && firstId > 0 && prevFirst > 0 && firstId < prevFirst) {
el.scrollTop = el.scrollHeight - prevScrollHeightRef.current
return
}
if (stickRef.current) el.scrollTop = el.scrollHeight
}, [messages])
if (loading) {
return (
<div className="flex flex-1 flex-col justify-end gap-3 px-5 py-4">
{[0, 1, 2, 3].map((row) => (
<div key={row} className={`flex ${row % 2 === 0 ? 'justify-start' : 'justify-end'}`}>
<div
className={`h-10 animate-pulse-soft rounded-2xl bg-surface-2 ${row % 2 === 0 ? 'w-48' : 'w-40'}`}
style={{ animationDelay: `${row * 90}ms` }}
/>
</div>
))}
</div>
)
}
if (error && messages.length === 0) {
return (
<div className="flex flex-1 flex-col items-center justify-center gap-2 px-6 text-center">
<p className="text-sm text-danger">{error}</p>
</div>
)
}
if (messages.length === 0) {
return (
<div className="flex flex-1 flex-col items-center justify-center gap-3 px-6 text-center">
<div className="flex h-14 w-14 items-center justify-center rounded-2xl border border-line bg-surface-2 text-2xl">
👋
</div>
<p className="font-display text-base font-semibold text-fg">Пока пусто</p>
<p className="max-w-xs text-sm text-muted">Напишите первое сообщение — начните разговор.</p>
</div>
)
}
return (
<div
ref={scrollRef}
onScroll={handleScroll}
role="log"
aria-live="polite"
aria-label="Сообщения"
className="scrollbar-slim flex flex-1 flex-col gap-1.5 overflow-y-auto py-4"
>
{loadingOlder && (
<div className="flex justify-center py-2" aria-hidden="true">
<span className="h-4 w-4 animate-spin rounded-full border-2 border-surface-3 border-t-accent" />
</div>
)}
{messages.map((message, index) => {
const previous = index > 0 ? messages[index - 1] : null
const dayChanged = !previous || !isSameDay(previous.created_at, message.created_at)
const newGroup =
dayChanged ||
!previous ||
previous.sender_id !== message.sender_id ||
minutesBetween(previous.created_at, message.created_at) > GROUP_WINDOW_MINUTES
const isOwn = message.sender_id === currentUser.id
return (
<div key={message.temp_id ?? message.id} className="flex flex-col gap-1.5">
{dayChanged && (
<div className="my-3 flex items-center justify-center">
<span className="rounded-full border border-line/70 bg-surface/80 px-3 py-1 text-[11px] font-medium text-muted backdrop-blur">
{formatDayDivider(message.created_at)}
</span>
</div>
)}
<MessageBubble
message={message}
isOwn={isOwn}
sender={directory.get(message.sender_id) ?? null}
showAvatar={newGroup}
showName={newGroup}
onRetry={onRetry}
/>
</div>
)
})}
<div className="h-1" aria-hidden="true" />
</div>
)
}
+208
View File
@@ -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>
)
}
+222
View File
@@ -0,0 +1,222 @@
import { useMemo, useState } from 'react'
import Avatar from './Avatar'
import { IconLogout, IconPlus, IconSearch } from './icons'
import { formatListTime, peerForConversation, userTitle } from '../lib/format'
import type { Conversation, User } from '../lib/types'
interface SidebarProps {
className?: string
conversations: Conversation[]
loading: boolean
error: string | null
selectedId: number | null
currentUser: User
directory: Map<number, User>
peers: Record<number, number>
presence: Record<number, boolean>
typing: Record<number, boolean>
onSelect: (id: number) => void
onNewChat: () => void
onRetry: () => void
onLogout: () => void
}
function previewOf(conversation: Conversation, currentUserId: number): string {
const last = conversation.last_message
if (!last) return 'Нет сообщений'
const prefix = last.sender_id === currentUserId ? 'Вы: ' : ''
return `${prefix}${last.body}`
}
export default function Sidebar({
className = '',
conversations,
loading,
error,
selectedId,
currentUser,
directory,
peers,
presence,
typing,
onSelect,
onNewChat,
onRetry,
onLogout,
}: SidebarProps) {
const [query, setQuery] = useState('')
const items = useMemo(() => {
const term = query.trim().toLowerCase()
return conversations
.map((conversation) => {
const peer = peerForConversation(conversation, peers, directory, currentUser.id)
const title = peer ? userTitle(peer) : `Чат #${conversation.id}`
return { conversation, peer, title, preview: previewOf(conversation, currentUser.id) }
})
.filter((item) => !term || item.title.toLowerCase().includes(term) || item.preview.toLowerCase().includes(term))
}, [conversations, query, peers, directory, currentUser.id])
return (
<aside className={`${className} w-full flex-col border-line/80 bg-panel/60 md:w-[340px] md:border-r`}>
<div className="flex items-center justify-between gap-3 px-4 pt-4 pb-3">
<div className="flex items-center gap-2.5">
<span className="flex h-9 w-9 items-center justify-center rounded-xl bg-gradient-to-br from-accent to-accent-strong text-white shadow-lg shadow-accent/20">
<svg viewBox="0 0 24 24" className="h-5 w-5" fill="none" aria-hidden="true">
<path
d="M4 12.5h3l2-5 3.2 9 2.3-6 1.5 2H20"
stroke="currentColor"
strokeWidth="1.9"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</span>
<span className="font-display text-lg font-bold tracking-tight text-fg">Пульс</span>
</div>
<button
type="button"
onClick={onNewChat}
aria-label="Новый чат"
className="inline-flex h-9 w-9 items-center justify-center rounded-xl border border-line bg-surface text-muted transition hover:border-accent/50 hover:text-accent"
>
<IconPlus className="h-5 w-5" />
</button>
</div>
<div className="px-4 pb-2">
<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="chat-search" className="sr-only">
Поиск по чатам
</label>
<input
id="chat-search"
type="search"
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="Поиск"
className="w-full rounded-xl border border-line bg-surface/70 py-2 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>
</div>
<div className="scrollbar-slim flex-1 overflow-y-auto px-2 py-1">
{loading && (
<div className="space-y-1.5 p-1">
{[0, 1, 2, 3, 4].map((row) => (
<div key={row} className="flex animate-pulse-soft items-center gap-3 rounded-xl p-2.5" style={{ animationDelay: `${row * 70}ms` }}>
<span className="h-11 w-11 rounded-full bg-surface-2" />
<span className="flex-1 space-y-2">
<span className="block h-3 w-1/2 rounded-full bg-surface-2" />
<span className="block h-3 w-3/4 rounded-full bg-surface-2/70" />
</span>
</div>
))}
</div>
)}
{!loading && error && (
<div className="m-2 rounded-2xl border border-line bg-surface/60 p-4 text-center">
<p className="text-sm text-danger">{error}</p>
<button
type="button"
onClick={onRetry}
className="mt-3 rounded-lg border border-line px-3 py-1.5 text-xs font-medium text-fg transition hover:border-accent/50 hover:text-accent"
>
Повторить
</button>
</div>
)}
{!loading && !error && items.length === 0 && (
<div className="flex flex-col items-center gap-3 px-6 py-12 text-center">
<div className="flex h-14 w-14 items-center justify-center rounded-2xl border border-line bg-surface-2 text-accent">
<IconPlus className="h-6 w-6" />
</div>
<p className="text-sm text-muted">
{query ? 'Ничего не найдено' : 'Пока нет чатов. Начните первый разговор.'}
</p>
{!query && (
<button
type="button"
onClick={onNewChat}
className="rounded-xl bg-gradient-to-br from-accent to-accent-strong px-4 py-2 text-sm font-semibold text-white transition hover:brightness-110"
>
Новый чат
</button>
)}
</div>
)}
<ul className="space-y-0.5">
{!loading &&
!error &&
items.map(({ conversation, peer, title, preview }) => {
const selected = conversation.id === selectedId
const isTyping = typing[conversation.id] === true
const unread = conversation.unread_count ?? 0
const lastAt = conversation.last_message?.created_at ?? conversation.created_at
return (
<li key={conversation.id}>
<button
type="button"
onClick={() => onSelect(conversation.id)}
aria-current={selected ? 'true' : undefined}
className={`flex w-full items-center gap-3 rounded-xl px-2.5 py-2.5 text-left transition ${
selected ? 'bg-accent-ghost ring-1 ring-accent/30' : 'hover:bg-surface-2/70'
}`}
>
<Avatar
name={title}
seed={peer?.id ?? conversation.id}
size="lg"
online={peer ? presence[peer.id] === true : false}
/>
<span className="min-w-0 flex-1">
<span className="flex items-baseline justify-between gap-2">
<span className={`truncate text-sm font-semibold ${selected ? 'text-fg' : 'text-fg/90'}`}>
{title}
</span>
<span className="shrink-0 text-[11px] text-faint">{formatListTime(lastAt)}</span>
</span>
<span className="mt-0.5 flex items-center justify-between gap-2">
<span className={`truncate text-xs ${isTyping ? 'text-accent' : 'text-muted'}`}>
{isTyping ? 'печатает…' : preview}
</span>
{unread > 0 && (
<span className="inline-flex h-5 min-w-5 shrink-0 items-center justify-center rounded-full bg-accent px-1.5 text-[11px] font-bold text-white">
{unread > 99 ? '99+' : unread}
</span>
)}
</span>
</span>
</button>
</li>
)
})}
</ul>
</div>
<div className="flex items-center gap-3 border-t border-line/80 px-4 py-3">
<Avatar name={currentUser.display_name || currentUser.username} seed={currentUser.id} size="md" online />
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-semibold text-fg">
{currentUser.display_name || currentUser.username}
</p>
<p className="truncate text-xs text-muted">@{currentUser.username}</p>
</div>
<button
type="button"
onClick={onLogout}
aria-label="Выйти"
title="Выйти"
className="inline-flex h-9 w-9 items-center justify-center rounded-xl text-muted transition hover:bg-surface-2 hover:text-danger"
>
<IconLogout className="h-5 w-5" />
</button>
</div>
</aside>
)
}
+110
View File
@@ -0,0 +1,110 @@
interface IconProps {
className?: string
}
export function IconSend({ className }: IconProps) {
return (
<svg className={className} viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path
d="M4.6 11.2 19 4.4c.8-.4 1.6.4 1.3 1.2l-5.1 14.1c-.3.9-1.6.9-1.9 0l-1.6-4.7a1 1 0 0 0-.6-.6l-4.7-1.6c-.9-.3-.9-1.6.2-1.6Z"
fill="currentColor"
/>
</svg>
)
}
export function IconSearch({ className }: IconProps) {
return (
<svg className={className} viewBox="0 0 24 24" fill="none" aria-hidden="true">
<circle cx="11" cy="11" r="6.5" stroke="currentColor" strokeWidth="1.7" />
<path d="m16 16 4 4" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" />
</svg>
)
}
export function IconPlus({ className }: IconProps) {
return (
<svg className={className} viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="M12 5v14M5 12h14" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" />
</svg>
)
}
export function IconLogout({ className }: IconProps) {
return (
<svg className={className} viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path
d="M9 5H6.5A1.5 1.5 0 0 0 5 6.5v11A1.5 1.5 0 0 0 6.5 19H9"
stroke="currentColor"
strokeWidth="1.7"
strokeLinecap="round"
/>
<path d="M15 8.5 18.5 12 15 15.5M18 12H9" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round" />
</svg>
)
}
export function IconClose({ className }: IconProps) {
return (
<svg className={className} viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="m6 6 12 12M18 6 6 18" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" />
</svg>
)
}
export function IconArrowLeft({ className }: IconProps) {
return (
<svg className={className} viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="M19 12H5m0 0 6-6m-6 6 6 6" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" />
</svg>
)
}
export function IconCheck({ className }: IconProps) {
return (
<svg className={className} viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="m5 12.5 4.5 4.5L19 7" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
)
}
export function IconCheckCheck({ className }: IconProps) {
return (
<svg className={className} viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="m2 12.5 4.5 4.5L15 8.5" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
<path d="m9.5 16.5 1 1L22 6" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
)
}
export function IconAlert({ className }: IconProps) {
return (
<svg className={className} viewBox="0 0 24 24" fill="none" aria-hidden="true">
<circle cx="12" cy="12" r="8.5" stroke="currentColor" strokeWidth="1.7" />
<path d="M12 7.5v5M12 16h.01" stroke="currentColor" strokeWidth="1.9" strokeLinecap="round" />
</svg>
)
}
export function IconChat({ className }: IconProps) {
return (
<svg className={className} viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path
d="M4 6.5A2.5 2.5 0 0 1 6.5 4h11A2.5 2.5 0 0 1 20 6.5v7a2.5 2.5 0 0 1-2.5 2.5H9l-4.2 3.4A.7.7 0 0 1 4 18.8V6.5Z"
stroke="currentColor"
strokeWidth="1.6"
strokeLinejoin="round"
/>
</svg>
)
}
export function IconUserPlus({ className }: IconProps) {
return (
<svg className={className} viewBox="0 0 24 24" fill="none" aria-hidden="true">
<circle cx="10" cy="8" r="3.4" stroke="currentColor" strokeWidth="1.6" />
<path d="M4.5 19a5.5 5.5 0 0 1 11 0" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" />
<path d="M18.5 8v6M15.5 11h6" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" />
</svg>
)
}
+23 -5
View File
@@ -26,22 +26,40 @@ export function useConversations(enabled = true): UseConversationsResult {
const [loading, setLoading] = useState(enabled) const [loading, setLoading] = useState(enabled)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const load = useCallback(async () => {
const list = await api.listConversations()
setConversations(sortConversations(list))
}, [])
const reload = useCallback(async () => { const reload = useCallback(async () => {
setLoading(true) setLoading(true)
setError(null) setError(null)
try { try {
const list = await api.listConversations() await load()
setConversations(sortConversations(list))
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : 'Не удалось загрузить чаты') setError(err instanceof Error ? err.message : 'Не удалось загрузить чаты')
} finally { } finally {
setLoading(false) setLoading(false)
} }
}, []) }, [load])
useEffect(() => { useEffect(() => {
if (enabled) void reload() if (!enabled) return
}, [enabled, reload]) let cancelled = false
;(async () => {
try {
const list = await api.listConversations()
if (!cancelled) setConversations(sortConversations(list))
} catch (err) {
if (!cancelled) setError(err instanceof Error ? err.message : 'Не удалось загрузить чаты')
} finally {
if (!cancelled) setLoading(false)
}
})()
return () => {
cancelled = true
}
}, [enabled])
const add = useCallback((conversation: Conversation) => { const add = useCallback((conversation: Conversation) => {
setConversations((prev) => setConversations((prev) =>
+10 -10
View File
@@ -24,6 +24,15 @@ export function useMessages(conversationId: number | null, currentUserId: number
const [loadingOlder, setLoadingOlder] = useState(false) const [loadingOlder, setLoadingOlder] = useState(false)
const [hasMore, setHasMore] = useState(false) const [hasMore, setHasMore] = useState(false)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [trackedId, setTrackedId] = useState(conversationId)
if (trackedId !== conversationId) {
setTrackedId(conversationId)
setMessages([])
setHasMore(false)
setError(null)
setLoading(conversationId !== null)
}
const messagesRef = useRef(messages) const messagesRef = useRef(messages)
const hasMoreRef = useRef(hasMore) const hasMoreRef = useRef(hasMore)
@@ -38,18 +47,9 @@ export function useMessages(conversationId: number | null, currentUserId: number
}, [hasMore]) }, [hasMore])
useEffect(() => { useEffect(() => {
if (conversationId === null) { if (conversationId === null) return
setMessages([])
setHasMore(false)
setError(null)
setLoading(false)
return
}
let cancelled = false let cancelled = false
setLoading(true)
setError(null)
setMessages([])
api api
.listMessages(conversationId, undefined, PAGE_SIZE) .listMessages(conversationId, undefined, PAGE_SIZE)
+4 -1
View File
@@ -1,10 +1,13 @@
import { StrictMode } from 'react' import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client' import { createRoot } from 'react-dom/client'
import './index.css' import './index.css'
import App from './App.tsx' import App from './App'
import { AuthProvider } from './auth/AuthContext'
createRoot(document.getElementById('root')!).render( createRoot(document.getElementById('root')!).render(
<StrictMode> <StrictMode>
<AuthProvider>
<App /> <App />
</AuthProvider>
</StrictMode>, </StrictMode>,
) )
+253
View File
@@ -0,0 +1,253 @@
import { useCallback, useState } from 'react'
import type { FormEvent } from 'react'
import { useAuth } from '../auth/AuthContext'
import { IconAlert } from '../components/icons'
type Mode = 'login' | 'register'
interface FieldErrors {
username?: string
displayName?: string
password?: string
}
const USERNAME_PATTERN = /^[a-z0-9_]{3,32}$/
function PulseMark({ className }: { className?: string }) {
return (
<svg viewBox="0 0 24 24" className={className} fill="none" aria-hidden="true">
<path
d="M4 12.5h3l2-5 3.2 9 2.3-6 1.5 2H20"
stroke="currentColor"
strokeWidth="1.9"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
)
}
export default function AuthScreen() {
const { login, register } = useAuth()
const [mode, setMode] = useState<Mode>('login')
const [username, setUsername] = useState('')
const [displayName, setDisplayName] = useState('')
const [password, setPassword] = useState('')
const [showPassword, setShowPassword] = useState(false)
const [errors, setErrors] = useState<FieldErrors>({})
const [apiError, setApiError] = useState<string | null>(null)
const [submitting, setSubmitting] = useState(false)
const switchMode = useCallback((next: Mode) => {
setMode(next)
setErrors({})
setApiError(null)
}, [])
const validate = useCallback((): FieldErrors => {
const next: FieldErrors = {}
if (!USERNAME_PATTERN.test(username)) {
next.username = 'От 3 до 32 символов: латиница, цифры и «_»'
}
if (mode === 'register' && displayName.trim().length === 0) {
next.displayName = 'Укажите, как вас показывать'
}
if (password.length < 8) {
next.password = 'Минимум 8 символов'
}
return next
}, [username, displayName, password, mode])
const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault()
const nextErrors = validate()
setErrors(nextErrors)
if (Object.keys(nextErrors).length > 0) return
setSubmitting(true)
setApiError(null)
try {
if (mode === 'login') {
await login(username, password)
} else {
await register(username, displayName.trim(), password)
}
} catch (err) {
setApiError(err instanceof Error ? err.message : 'Не удалось выполнить запрос')
} finally {
setSubmitting(false)
}
}
return (
<div className="grid min-h-full grid-cols-1 lg:grid-cols-[1.05fr_1fr]">
<aside className="relative hidden overflow-hidden border-r border-line/70 bg-panel/50 lg:flex">
<div className="absolute inset-0 opacity-90" aria-hidden="true">
<div className="absolute -top-24 -left-16 h-96 w-96 rounded-full bg-accent/25 blur-3xl" />
<div className="absolute right-0 bottom-0 h-80 w-80 rounded-full bg-online/10 blur-3xl" />
</div>
<div className="relative z-10 flex flex-col justify-between p-12">
<div className="flex items-center gap-3">
<span className="flex h-11 w-11 items-center justify-center rounded-2xl bg-gradient-to-br from-accent to-accent-strong text-white shadow-xl shadow-accent/25">
<PulseMark className="h-6 w-6" />
</span>
<span className="font-display text-2xl font-bold tracking-tight">Пульс</span>
</div>
<div className="max-w-md space-y-4">
<h1 className="font-display text-4xl leading-tight font-bold text-balance">
Тёплый мессенджер для своих
</h1>
<p className="text-base leading-relaxed text-muted">
Личные диалоги, живая доставка и статусы прочтения. Всё работает на вашем домашнем сервере —
без лишнего шума и слежки.
</p>
</div>
<ul className="flex flex-wrap gap-2 text-xs text-muted">
<li className="rounded-full border border-line bg-surface/60 px-3 py-1.5">Мгновенная доставка</li>
<li className="rounded-full border border-line bg-surface/60 px-3 py-1.5">Печатает и онлайн</li>
<li className="rounded-full border border-line bg-surface/60 px-3 py-1.5">Галочки прочтения</li>
</ul>
</div>
</aside>
<main className="flex items-center justify-center px-5 py-10 sm:px-8">
<div className="w-full max-w-sm animate-rise">
<div className="mb-8 flex items-center gap-3 lg:hidden">
<span className="flex h-10 w-10 items-center justify-center rounded-2xl bg-gradient-to-br from-accent to-accent-strong text-white shadow-lg shadow-accent/25">
<PulseMark className="h-5 w-5" />
</span>
<span className="font-display text-xl font-bold tracking-tight">Пульс</span>
</div>
<h2 className="font-display text-2xl font-bold text-fg">
{mode === 'login' ? 'С возвращением' : 'Создайте аккаунт'}
</h2>
<p className="mt-1 text-sm text-muted">
{mode === 'login' ? 'Войдите, чтобы продолжить общение' : 'Это займёт меньше минуты'}
</p>
<div className="mt-6 grid grid-cols-2 gap-1 rounded-2xl border border-line bg-surface/60 p-1" role="tablist">
{(['login', 'register'] as const).map((item) => (
<button
key={item}
type="button"
role="tab"
aria-selected={mode === item}
onClick={() => switchMode(item)}
className={`rounded-xl px-3 py-2 text-sm font-semibold transition ${
mode === item
? 'bg-gradient-to-br from-accent to-accent-strong text-white shadow-lg shadow-accent/20'
: 'text-muted hover:text-fg'
}`}
>
{item === 'login' ? 'Вход' : 'Регистрация'}
</button>
))}
</div>
<form onSubmit={handleSubmit} className="mt-6 space-y-4" noValidate>
<div>
<label htmlFor="username" className="mb-1.5 block text-sm font-medium text-fg">
Имя пользователя
</label>
<input
id="username"
name="username"
autoComplete="username"
value={username}
onChange={(event) => setUsername(event.target.value)}
aria-invalid={Boolean(errors.username)}
aria-describedby="username-hint"
placeholder="your_name"
className={`w-full rounded-xl border bg-surface/70 px-3.5 py-2.5 text-sm text-fg placeholder:text-faint focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/20 ${
errors.username ? 'border-danger/70' : 'border-line focus:border-accent/60'
}`}
/>
<p id="username-hint" className={`mt-1.5 text-xs ${errors.username ? 'text-danger' : 'text-faint'}`}>
{errors.username ?? '3–32 символа: латиница, цифры и «_»'}
</p>
</div>
{mode === 'register' && (
<div>
<label htmlFor="displayName" className="mb-1.5 block text-sm font-medium text-fg">
Отображаемое имя
</label>
<input
id="displayName"
name="displayName"
autoComplete="name"
value={displayName}
onChange={(event) => setDisplayName(event.target.value)}
aria-invalid={Boolean(errors.displayName)}
aria-describedby="displayName-hint"
placeholder="Анна Смирнова"
className={`w-full rounded-xl border bg-surface/70 px-3.5 py-2.5 text-sm text-fg placeholder:text-faint focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/20 ${
errors.displayName ? 'border-danger/70' : 'border-line focus:border-accent/60'
}`}
/>
<p
id="displayName-hint"
className={`mt-1.5 text-xs ${errors.displayName ? 'text-danger' : 'text-faint'}`}
>
{errors.displayName ?? 'Так вас увидят собеседники'}
</p>
</div>
)}
<div>
<label htmlFor="password" className="mb-1.5 block text-sm font-medium text-fg">
Пароль
</label>
<div className="relative">
<input
id="password"
name="password"
type={showPassword ? 'text' : 'password'}
autoComplete={mode === 'login' ? 'current-password' : 'new-password'}
value={password}
onChange={(event) => setPassword(event.target.value)}
aria-invalid={Boolean(errors.password)}
aria-describedby="password-hint"
placeholder="••••••••"
className={`w-full rounded-xl border bg-surface/70 px-3.5 py-2.5 pr-20 text-sm text-fg placeholder:text-faint focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/20 ${
errors.password ? 'border-danger/70' : 'border-line focus:border-accent/60'
}`}
/>
<button
type="button"
onClick={() => setShowPassword((prev) => !prev)}
className="absolute top-1/2 right-2 -translate-y-1/2 rounded-lg px-2.5 py-1 text-xs font-medium text-muted transition hover:bg-surface-2 hover:text-fg"
>
{showPassword ? 'Скрыть' : 'Показать'}
</button>
</div>
<p id="password-hint" className={`mt-1.5 text-xs ${errors.password ? 'text-danger' : 'text-faint'}`}>
{errors.password ?? 'Минимум 8 символов'}
</p>
</div>
{apiError && (
<div
role="alert"
className="flex items-start gap-2 rounded-xl border border-danger/40 bg-danger/10 px-3.5 py-2.5 text-sm text-danger"
>
<IconAlert className="mt-0.5 h-4 w-4 shrink-0" />
<span>{apiError}</span>
</div>
)}
<button
type="submit"
disabled={submitting}
className="flex w-full items-center justify-center gap-2 rounded-xl bg-gradient-to-br from-accent to-accent-strong px-4 py-3 text-sm font-semibold text-white shadow-lg shadow-accent/25 transition enabled:hover:brightness-110 enabled:active:scale-[0.99] disabled:cursor-not-allowed disabled:opacity-60"
>
{submitting && <span className="h-4 w-4 animate-spin rounded-full border-2 border-white/40 border-t-white" />}
{mode === 'login' ? 'Войти' : 'Зарегистрироваться'}
</button>
</form>
</div>
</main>
</div>
)
}
+296
View File
@@ -0,0 +1,296 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import Sidebar from '../components/Sidebar'
import ChatWindow from '../components/ChatWindow'
import NewChatDialog from '../components/NewChatDialog'
import { api } from '../lib/api'
import { useAuth } from '../auth/AuthContext'
import { useConversations } from '../hooks/useConversations'
import { useMessages } from '../hooks/useMessages'
import { useRealtime } from '../hooks/useRealtime'
import type { ClientMessage, Message, User, WsEvent } from '../lib/types'
interface ChatAppProps {
user: User
}
export default function ChatApp({ user }: ChatAppProps) {
const { logout } = useAuth()
const [selectedId, setSelectedId] = useState<number | null>(null)
const [directory, setDirectory] = useState<Map<number, User>>(() => new Map([[user.id, user]]))
const [learnedPeers, setLearnedPeers] = useState<Record<number, number>>({})
const [presence, setPresence] = useState<Record<number, boolean>>({})
const [typing, setTyping] = useState<Record<number, boolean>>({})
const [dialogOpen, setDialogOpen] = useState(false)
const [focusTick, setFocusTick] = useState(0)
const { conversations, loading, error, reload, add, patch } = useConversations()
const {
messages,
loading: messagesLoading,
loadingOlder,
hasMore,
error: messagesError,
loadOlder,
upsert,
addOptimistic,
markSending,
failOptimistic,
markReadUpTo,
} = useMessages(selectedId, user.id)
const selectedIdRef = useRef(selectedId)
const conversationsRef = useRef(conversations)
const typingTimers = useRef(new Map<number, number>())
useEffect(() => {
selectedIdRef.current = selectedId
}, [selectedId])
useEffect(() => {
conversationsRef.current = conversations
}, [conversations])
const rememberUser = useCallback((next: User) => {
setDirectory((prev) => {
const existing = prev.get(next.id)
if (
existing &&
existing.display_name === next.display_name &&
existing.username === next.username &&
existing.avatar === next.avatar
) {
return prev
}
const map = new Map(prev)
map.set(next.id, next)
return map
})
}, [])
const loadContacts = useCallback(async () => {
const contacts = await api.listContacts().catch(() => [])
contacts.forEach((contact) => rememberUser(contact.user))
}, [rememberUser])
useEffect(() => {
void loadContacts()
}, [loadContacts])
useEffect(() => {
const onFocus = () => setFocusTick((tick) => tick + 1)
window.addEventListener('focus', onFocus)
return () => window.removeEventListener('focus', onFocus)
}, [])
const peers = useMemo(() => {
const map: Record<number, number> = { ...learnedPeers }
for (const conversation of conversations) {
if (map[conversation.id] !== undefined) continue
const last = conversation.last_message
if (last && last.sender_id !== user.id) map[conversation.id] = last.sender_id
}
return map
}, [learnedPeers, conversations, user.id])
const handleEvent = useCallback(
(event: WsEvent) => {
switch (event.type) {
case 'message.created': {
const { temp_id, conversation_id, message_id, sender_id, body, created_at } = event.data
const message: Message = { id: message_id, conversation_id, sender_id, body, created_at }
if (conversation_id === selectedIdRef.current || sender_id === user.id) {
upsert(message, temp_id)
}
if (!conversationsRef.current.some((conversation) => conversation.id === conversation_id)) {
void reload()
return
}
patch(conversation_id, (conversation) => {
const mine = sender_id === user.id
const open = conversation_id === selectedIdRef.current
const unread_count = mine || open ? 0 : (conversation.unread_count ?? 0) + 1
return { ...conversation, last_message: message, unread_count }
})
break
}
case 'typing': {
const { conversation_id, user_id, is_typing } = event.data
if (user_id === user.id) break
const timers = typingTimers.current
const existing = timers.get(conversation_id)
if (existing !== undefined) {
window.clearTimeout(existing)
timers.delete(conversation_id)
}
if (is_typing) {
const timer = window.setTimeout(() => {
timers.delete(conversation_id)
setTyping((prev) => (prev[conversation_id] ? { ...prev, [conversation_id]: false } : prev))
}, 3000)
timers.set(conversation_id, timer)
}
setTyping((prev) =>
prev[conversation_id] === is_typing ? prev : { ...prev, [conversation_id]: is_typing },
)
break
}
case 'presence': {
const { user_id, status } = event.data
const online = status === 'online'
setPresence((prev) => (prev[user_id] === online ? prev : { ...prev, [user_id]: online }))
break
}
case 'read': {
const { conversation_id, user_id, message_id } = event.data
if (user_id === user.id) break
if (conversation_id === selectedIdRef.current) markReadUpTo(message_id)
break
}
}
},
[markReadUpTo, patch, reload, upsert, user.id],
)
const realtime = useRealtime(handleEvent)
const sendMessage = useCallback(
async (conversationId: number, body: string) => {
const text = body.trim()
if (!text) return
const tempId = crypto.randomUUID()
const optimistic: ClientMessage = {
id: -Date.now(),
conversation_id: conversationId,
sender_id: user.id,
body: text,
created_at: new Date().toISOString(),
temp_id: tempId,
status: 'sending',
}
addOptimistic(optimistic)
patch(conversationId, (conversation) => ({ ...conversation, last_message: optimistic, unread_count: 0 }))
try {
const result = await api.sendMessage(conversationId, text, tempId)
upsert(result.message, tempId)
patch(conversationId, (conversation) => ({
...conversation,
last_message: result.message,
unread_count: 0,
}))
} catch {
failOptimistic(tempId)
}
},
[addOptimistic, failOptimistic, patch, upsert, user.id],
)
const resendMessage = useCallback(
async (message: ClientMessage) => {
const tempId = message.temp_id
if (!tempId) return
markSending(tempId)
try {
const result = await api.sendMessage(message.conversation_id, message.body, tempId)
upsert(result.message, tempId)
patch(message.conversation_id, (conversation) => ({
...conversation,
last_message: result.message,
unread_count: 0,
}))
} catch {
failOptimistic(tempId)
}
},
[failOptimistic, markSending, patch, upsert],
)
const sendTyping = useCallback(
(conversationId: number, isTyping: boolean) => {
realtime.sendTyping(conversationId, isTyping)
},
[realtime],
)
const handleSelect = useCallback(
(id: number) => {
setSelectedId(id)
patch(id, (conversation) =>
conversation.unread_count ? { ...conversation, unread_count: 0 } : conversation,
)
},
[patch],
)
const handleStartChat = useCallback(
async (target: User) => {
const conversation = await api.createDM(target.id)
rememberUser(target)
setLearnedPeers((prev) => ({ ...prev, [conversation.id]: target.id }))
add(conversation)
setSelectedId(conversation.id)
void loadContacts()
},
[add, loadContacts, rememberUser],
)
const lastMessage = messages.length > 0 ? messages[messages.length - 1] : null
const lastMessageId = lastMessage && lastMessage.id > 0 ? lastMessage.id : null
useEffect(() => {
if (selectedId === null || lastMessageId === null) return
if (document.hasFocus()) realtime.sendRead(selectedId, lastMessageId)
}, [selectedId, lastMessageId, realtime, focusTick])
const peerIsTyping = selectedId !== null && typing[selectedId] === true
return (
<div className="h-full p-0 md:p-5">
<div className="mx-auto flex h-full max-w-[1440px] overflow-hidden border-line bg-panel/70 backdrop-blur-xl md:rounded-[28px] md:border md:shadow-2xl md:shadow-black/40">
<Sidebar
className={selectedId !== null ? 'hidden md:flex' : 'flex'}
conversations={conversations}
loading={loading}
error={error}
selectedId={selectedId}
currentUser={user}
directory={directory}
peers={peers}
presence={presence}
typing={typing}
onSelect={handleSelect}
onNewChat={() => setDialogOpen(true)}
onRetry={() => void reload()}
onLogout={() => void logout()}
/>
<ChatWindow
className={selectedId === null ? 'hidden md:flex' : 'flex'}
conversation={conversations.find((conversation) => conversation.id === selectedId) ?? null}
currentUser={user}
directory={directory}
peers={peers}
presence={presence}
peerIsTyping={peerIsTyping}
messages={messages}
loading={messagesLoading}
loadingOlder={loadingOlder}
hasMore={hasMore}
error={messagesError}
onBack={() => setSelectedId(null)}
onSend={sendMessage}
onTyping={sendTyping}
onLoadOlder={() => void loadOlder()}
onRetry={resendMessage}
/>
</div>
{dialogOpen && (
<NewChatDialog
onClose={() => setDialogOpen(false)}
onStartChat={handleStartChat}
onContactsChanged={() => void loadContacts()}
/>
)}
</div>
)
}