223 lines
9.1 KiB
TypeScript
223 lines
9.1 KiB
TypeScript
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>
|
|
)
|
|
}
|