frontend: now working fully, fuctionality tested
This commit is contained in:
+30
-44
@@ -1,50 +1,36 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { pingApi, type PingResponse } from './api'
|
||||
import './index.css'
|
||||
|
||||
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
|
||||
}
|
||||
}, [])
|
||||
import { useAuth } from './auth/AuthContext'
|
||||
import AuthScreen from './screens/AuthScreen'
|
||||
import ChatApp from './screens/ChatApp'
|
||||
|
||||
function Splash() {
|
||||
return (
|
||||
<main className="shell">
|
||||
<h1>Messenger</h1>
|
||||
<div className="card">
|
||||
<div className="status-row">
|
||||
<span className={`dot ${api.status}`} aria-hidden />
|
||||
<span className="status-label">
|
||||
{api.status === 'loading' && 'API: connecting…'}
|
||||
{api.status === 'ok' && 'API: connected'}
|
||||
{api.status === 'error' && 'API: unreachable'}
|
||||
</span>
|
||||
</div>
|
||||
{api.status === 'ok' && (
|
||||
<p className="detail">
|
||||
{api.data.message} · server time {api.data.time}
|
||||
</p>
|
||||
)}
|
||||
{api.status === 'error' && <p className="detail">{api.error}</p>}
|
||||
<div className="flex min-h-full flex-col items-center justify-center gap-5">
|
||||
<div className="relative flex h-20 w-20 items-center justify-center">
|
||||
<span className="absolute inset-0 animate-ping rounded-3xl bg-accent/20" aria-hidden="true" />
|
||||
<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">
|
||||
<svg viewBox="0 0 24 24" className="h-8 w-8" 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>
|
||||
</div>
|
||||
</main>
|
||||
<div className="text-center">
|
||||
<p className="font-display text-xl font-bold tracking-tight text-fg">Пульс</p>
|
||||
<p className="mt-1 text-sm text-muted">Подключаемся…</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default App
|
||||
export default function App() {
|
||||
const { user, loading } = useAuth()
|
||||
|
||||
if (loading) return <Splash />
|
||||
if (!user) return <AuthScreen />
|
||||
return <ChatApp user={user} />
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -71,7 +71,8 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
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)
|
||||
if (!context) throw new Error('useAuth должен использоваться внутри AuthProvider')
|
||||
return context
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -26,22 +26,40 @@ export function useConversations(enabled = true): UseConversationsResult {
|
||||
const [loading, setLoading] = useState(enabled)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
const list = await api.listConversations()
|
||||
setConversations(sortConversations(list))
|
||||
}, [])
|
||||
|
||||
const reload = useCallback(async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const list = await api.listConversations()
|
||||
setConversations(sortConversations(list))
|
||||
await load()
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Не удалось загрузить чаты')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
}, [load])
|
||||
|
||||
useEffect(() => {
|
||||
if (enabled) void reload()
|
||||
}, [enabled, reload])
|
||||
if (!enabled) return
|
||||
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) => {
|
||||
setConversations((prev) =>
|
||||
|
||||
@@ -24,6 +24,15 @@ export function useMessages(conversationId: number | null, currentUserId: number
|
||||
const [loadingOlder, setLoadingOlder] = useState(false)
|
||||
const [hasMore, setHasMore] = useState(false)
|
||||
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 hasMoreRef = useRef(hasMore)
|
||||
@@ -38,18 +47,9 @@ export function useMessages(conversationId: number | null, currentUserId: number
|
||||
}, [hasMore])
|
||||
|
||||
useEffect(() => {
|
||||
if (conversationId === null) {
|
||||
setMessages([])
|
||||
setHasMore(false)
|
||||
setError(null)
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
if (conversationId === null) return
|
||||
|
||||
let cancelled = false
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
setMessages([])
|
||||
|
||||
api
|
||||
.listMessages(conversationId, undefined, PAGE_SIZE)
|
||||
|
||||
+5
-2
@@ -1,10 +1,13 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import './index.css'
|
||||
import App from './App.tsx'
|
||||
import App from './App'
|
||||
import { AuthProvider } from './auth/AuthContext'
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
<AuthProvider>
|
||||
<App />
|
||||
</AuthProvider>
|
||||
</StrictMode>,
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user