big update: added reactions, groups, media share
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import { avatarColor, initials } from '../lib/format'
|
||||
import { IconUsers } from './icons'
|
||||
|
||||
type AvatarSize = 'sm' | 'md' | 'lg' | 'xl'
|
||||
|
||||
@@ -38,3 +39,26 @@ export default function Avatar({ name, seed, size = 'md', online = false, classN
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
interface GroupAvatarProps {
|
||||
name: string
|
||||
seed: number | string
|
||||
size?: AvatarSize
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function GroupAvatar({ name, seed, size = 'md', className = '' }: GroupAvatarProps) {
|
||||
const background = avatarColor(seed)
|
||||
return (
|
||||
<span className={`relative inline-flex shrink-0 ${className}`}>
|
||||
<span
|
||||
className={`inline-flex items-center justify-center rounded-full 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"
|
||||
title={name}
|
||||
>
|
||||
<IconUsers className="h-[55%] w-[55%]" />
|
||||
</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import Avatar from './Avatar'
|
||||
import Avatar, { GroupAvatar } 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'
|
||||
import MembersDialog from './MembersDialog'
|
||||
import { IconArrowLeft, IconChat, IconClose, IconFile, IconPaperclip, IconSend, IconUsers } from './icons'
|
||||
import { api } from '../lib/api'
|
||||
import { formatBytes, peerForConversation, userTitle } from '../lib/format'
|
||||
import type { ClientMessage, Conversation, Media, Member, User } from '../lib/types'
|
||||
|
||||
interface ChatWindowProps {
|
||||
className?: string
|
||||
@@ -18,11 +20,15 @@ interface ChatWindowProps {
|
||||
loadingOlder: boolean
|
||||
hasMore: boolean
|
||||
error: string | null
|
||||
membersVersion: number
|
||||
onBack: () => void
|
||||
onSend: (conversationId: number, body: string) => void
|
||||
onSend: (conversationId: number, body: string, media: Media | null) => void
|
||||
onTyping: (conversationId: number, isTyping: boolean) => void
|
||||
onLoadOlder: () => void
|
||||
onRetry: (message: ClientMessage) => void
|
||||
onReact: (message: ClientMessage, emoji: string) => void
|
||||
onMembersChanged: () => void
|
||||
onMembersLoaded: (members: Member[]) => void
|
||||
}
|
||||
|
||||
function TypingDots() {
|
||||
@@ -39,14 +45,26 @@ function TypingDots() {
|
||||
)
|
||||
}
|
||||
|
||||
function membersLabel(count: number): string {
|
||||
const mod10 = count % 10
|
||||
const mod100 = count % 100
|
||||
if (mod10 === 1 && mod100 !== 11) return `${count} участник`
|
||||
if (mod10 >= 2 && mod10 <= 4 && (mod100 < 12 || mod100 > 14)) return `${count} участника`
|
||||
return `${count} участников`
|
||||
}
|
||||
|
||||
interface ComposerProps {
|
||||
onSend: (body: string) => void
|
||||
onSend: (body: string, media: Media | null) => void
|
||||
onTyping: (isTyping: boolean) => void
|
||||
}
|
||||
|
||||
function Composer({ onSend, onTyping }: ComposerProps) {
|
||||
const [value, setValue] = useState('')
|
||||
const [attachment, setAttachment] = useState<Media | null>(null)
|
||||
const [uploading, setUploading] = useState(false)
|
||||
const [uploadError, setUploadError] = useState<string | null>(null)
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
const typingRef = useRef(false)
|
||||
const typingTimerRef = useRef<number | null>(null)
|
||||
|
||||
@@ -72,15 +90,17 @@ function Composer({ onSend, onTyping }: ComposerProps) {
|
||||
|
||||
const submit = useCallback(() => {
|
||||
const trimmed = value.trim()
|
||||
if (!trimmed) return
|
||||
onSend(trimmed)
|
||||
if ((!trimmed && !attachment) || uploading) return
|
||||
onSend(trimmed, attachment)
|
||||
setValue('')
|
||||
setAttachment(null)
|
||||
setUploadError(null)
|
||||
stopTyping()
|
||||
requestAnimationFrame(() => {
|
||||
resize()
|
||||
textareaRef.current?.focus()
|
||||
})
|
||||
}, [onSend, resize, stopTyping, value])
|
||||
}, [attachment, onSend, resize, stopTyping, uploading, value])
|
||||
|
||||
const handleChange = (next: string) => {
|
||||
setValue(next)
|
||||
@@ -96,9 +116,81 @@ function Composer({ onSend, onTyping }: ComposerProps) {
|
||||
}, 2000)
|
||||
}
|
||||
|
||||
const handleFile = async (file: File) => {
|
||||
setUploading(true)
|
||||
setUploadError(null)
|
||||
try {
|
||||
const media = await api.uploadMedia(file)
|
||||
setAttachment(media)
|
||||
} catch (err) {
|
||||
setUploadError(err instanceof Error ? err.message : 'Не удалось загрузить файл')
|
||||
} finally {
|
||||
setUploading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const isImage = attachment ? attachment.content_type.startsWith('image/') : false
|
||||
const canSend = (value.trim().length > 0 || attachment !== null) && !uploading
|
||||
|
||||
return (
|
||||
<div className="border-t border-line/80 bg-panel/70 px-3 py-3 backdrop-blur sm:px-5">
|
||||
{attachment && (
|
||||
<div className="mb-2 flex items-center gap-3 rounded-2xl border border-line bg-surface/80 p-2">
|
||||
{isImage ? (
|
||||
<img
|
||||
src={attachment.thumb_url ?? attachment.url}
|
||||
alt={attachment.filename}
|
||||
className="h-14 w-14 shrink-0 rounded-xl object-cover"
|
||||
/>
|
||||
) : (
|
||||
<span className="flex h-14 w-14 shrink-0 items-center justify-center rounded-xl bg-surface-3 text-accent">
|
||||
<IconFile className="h-6 w-6" />
|
||||
</span>
|
||||
)}
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-sm font-medium text-fg">{attachment.filename}</span>
|
||||
<span className="block text-[11px] text-muted">{formatBytes(attachment.size)}</span>
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAttachment(null)}
|
||||
aria-label="Убрать вложение"
|
||||
className="inline-flex h-8 w-8 items-center justify-center rounded-lg text-muted transition hover:bg-surface-2 hover:text-danger"
|
||||
>
|
||||
<IconClose className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{uploadError && <p className="mb-2 px-1 text-xs text-danger">{uploadError}</p>}
|
||||
|
||||
<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">
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*,*"
|
||||
className="hidden"
|
||||
onChange={(event) => {
|
||||
const file = event.target.files?.[0]
|
||||
event.target.value = ''
|
||||
if (file) void handleFile(file)
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={uploading}
|
||||
aria-label="Прикрепить файл"
|
||||
title="Прикрепить файл"
|
||||
className="mb-0.5 inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-xl text-muted transition hover:bg-surface-2 hover:text-accent disabled:opacity-50"
|
||||
>
|
||||
{uploading ? (
|
||||
<span className="h-4 w-4 animate-spin rounded-full border-2 border-surface-3 border-t-accent" />
|
||||
) : (
|
||||
<IconPaperclip className="h-5 w-5" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
<label htmlFor="composer" className="sr-only">
|
||||
Сообщение
|
||||
</label>
|
||||
@@ -120,7 +212,7 @@ function Composer({ onSend, onTyping }: ComposerProps) {
|
||||
<button
|
||||
type="button"
|
||||
onClick={submit}
|
||||
disabled={!value.trim()}
|
||||
disabled={!canSend}
|
||||
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"
|
||||
>
|
||||
@@ -147,12 +239,44 @@ export default function ChatWindow({
|
||||
loadingOlder,
|
||||
hasMore,
|
||||
error,
|
||||
membersVersion,
|
||||
onBack,
|
||||
onSend,
|
||||
onTyping,
|
||||
onLoadOlder,
|
||||
onRetry,
|
||||
onReact,
|
||||
onMembersChanged,
|
||||
onMembersLoaded,
|
||||
}: ChatWindowProps) {
|
||||
const [members, setMembers] = useState<Member[]>([])
|
||||
const [membersLoading, setMembersLoading] = useState(conversation?.type === 'group')
|
||||
const [membersOpen, setMembersOpen] = useState(false)
|
||||
|
||||
const conversationId = conversation?.id ?? null
|
||||
const isGroup = conversation?.type === 'group'
|
||||
|
||||
useEffect(() => {
|
||||
if (conversationId === null || !isGroup) return
|
||||
let cancelled = false
|
||||
api
|
||||
.listMembers(conversationId)
|
||||
.then((list) => {
|
||||
if (cancelled) return
|
||||
setMembers(list)
|
||||
onMembersLoaded(list)
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setMembers([])
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setMembersLoading(false)
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [conversationId, isGroup, membersVersion, onMembersLoaded])
|
||||
|
||||
if (!conversation) {
|
||||
return (
|
||||
<section className={`${className} flex-1 flex-col items-center justify-center gap-4 bg-panel/40 px-6 text-center`}>
|
||||
@@ -169,12 +293,17 @@ export default function ChatWindow({
|
||||
)
|
||||
}
|
||||
|
||||
const peer = peerForConversation(conversation, peers, directory, currentUser.id)
|
||||
const peerName = peer ? userTitle(peer) : `Чат #${conversation.id}`
|
||||
const peer = isGroup ? null : peerForConversation(conversation, peers, directory, currentUser.id)
|
||||
const title = isGroup
|
||||
? conversation.name || `Группа #${conversation.id}`
|
||||
: peer
|
||||
? userTitle(peer)
|
||||
: `Чат #${conversation.id}`
|
||||
const isOnline = peer ? presence[peer.id] === true : false
|
||||
|
||||
let subtitle = 'нет данных о статусе'
|
||||
if (peerIsTyping) subtitle = 'печатает…'
|
||||
if (isGroup) subtitle = membersLoading ? 'загрузка участников…' : membersLabel(members.length)
|
||||
else if (peerIsTyping) subtitle = 'печатает…'
|
||||
else if (isOnline) subtitle = 'в сети'
|
||||
else if (peer) subtitle = 'не в сети'
|
||||
|
||||
@@ -190,8 +319,10 @@ export default function ChatWindow({
|
||||
<IconArrowLeft className="h-5 w-5" />
|
||||
</button>
|
||||
|
||||
{peer ? (
|
||||
<Avatar name={peerName} seed={peer.id} size="md" online={isOnline} />
|
||||
{isGroup ? (
|
||||
<GroupAvatar name={title} seed={conversation.id} size="md" />
|
||||
) : peer ? (
|
||||
<Avatar name={title} 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">
|
||||
#
|
||||
@@ -199,12 +330,23 @@ export default function ChatWindow({
|
||||
)}
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<h2 className="truncate font-display text-[0.9375rem] font-semibold text-fg">{peerName}</h2>
|
||||
<h2 className="truncate font-display text-[0.9375rem] font-semibold text-fg">{title}</h2>
|
||||
<p className="flex items-center gap-1.5 truncate text-xs text-muted">
|
||||
{peerIsTyping && <TypingDots />}
|
||||
<span className={peerIsTyping ? 'text-accent' : ''}>{subtitle}</span>
|
||||
{peerIsTyping && !isGroup && <TypingDots />}
|
||||
<span className={peerIsTyping && !isGroup ? 'text-accent' : ''}>{subtitle}</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{isGroup && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMembersOpen(true)}
|
||||
className="inline-flex shrink-0 items-center gap-1.5 rounded-xl border border-line bg-surface px-3 py-2 text-xs font-medium text-muted transition hover:border-accent/50 hover:text-accent"
|
||||
>
|
||||
<IconUsers className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">Участники</span>
|
||||
</button>
|
||||
)}
|
||||
</header>
|
||||
|
||||
<MessageList
|
||||
@@ -217,13 +359,25 @@ export default function ChatWindow({
|
||||
error={error}
|
||||
onLoadOlder={onLoadOlder}
|
||||
onRetry={onRetry}
|
||||
onReact={onReact}
|
||||
/>
|
||||
|
||||
<Composer
|
||||
key={conversation.id}
|
||||
onSend={(body) => onSend(conversation.id, body)}
|
||||
onSend={(body, media) => onSend(conversation.id, body, media)}
|
||||
onTyping={(isTyping) => onTyping(conversation.id, isTyping)}
|
||||
/>
|
||||
|
||||
{isGroup && membersOpen && (
|
||||
<MembersDialog
|
||||
conversationId={conversation.id}
|
||||
currentUser={currentUser}
|
||||
members={members}
|
||||
loading={membersLoading}
|
||||
onClose={() => setMembersOpen(false)}
|
||||
onChanged={onMembersChanged}
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,240 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import Avatar from './Avatar'
|
||||
import { IconClose, IconSearch, IconTrash, IconUserPlus } from './icons'
|
||||
import { api } from '../lib/api'
|
||||
import { userTitle } from '../lib/format'
|
||||
import type { Member, User } from '../lib/types'
|
||||
|
||||
interface MembersDialogProps {
|
||||
conversationId: number
|
||||
currentUser: User
|
||||
members: Member[]
|
||||
loading: boolean
|
||||
onClose: () => void
|
||||
onChanged: () => void
|
||||
}
|
||||
|
||||
export default function MembersDialog({
|
||||
conversationId,
|
||||
currentUser,
|
||||
members,
|
||||
loading,
|
||||
onClose,
|
||||
onChanged,
|
||||
}: MembersDialogProps) {
|
||||
const [query, setQuery] = useState('')
|
||||
const [results, setResults] = useState<User[]>([])
|
||||
const [resultQuery, setResultQuery] = useState('')
|
||||
const [busyId, setBusyId] = useState<number | null>(null)
|
||||
const [error, setError] = useState<string | 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(() => {
|
||||
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 memberIds = useMemo(() => new Set(members.map((member) => member.user.id)), [members])
|
||||
const isOwner = members.some((member) => member.user.id === currentUser.id && member.role === 'owner')
|
||||
const trimmed = query.trim()
|
||||
const searching = trimmed.length > 0 && resultQuery !== trimmed
|
||||
const candidates = results.filter((user) => !memberIds.has(user.id))
|
||||
|
||||
const runAction = async (userId: number, action: () => Promise<void>) => {
|
||||
setBusyId(userId)
|
||||
setError(null)
|
||||
try {
|
||||
await action()
|
||||
onChanged()
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Не удалось выполнить действие')
|
||||
} finally {
|
||||
setBusyId(null)
|
||||
}
|
||||
}
|
||||
|
||||
const handleAdd = (user: User) =>
|
||||
runAction(user.id, async () => {
|
||||
await api.addMember(conversationId, user.id)
|
||||
setQuery('')
|
||||
setResults([])
|
||||
setResultQuery('')
|
||||
})
|
||||
|
||||
const handleRemove = (member: Member) =>
|
||||
runAction(member.user.id, async () => {
|
||||
await api.removeMember(conversationId, member.user.id)
|
||||
})
|
||||
|
||||
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="members-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="members-title" className="font-display text-base font-semibold text-fg">
|
||||
Участники
|
||||
</h2>
|
||||
<p className="text-xs text-muted">
|
||||
{loading ? 'Загрузка…' : `${members.length} в группе`}
|
||||
</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="member-search" className="sr-only">
|
||||
Поиск пользователей
|
||||
</label>
|
||||
<input
|
||||
id="member-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">
|
||||
{trimmed.length > 0 && (
|
||||
<div className="mb-3">
|
||||
<p className="px-2.5 pb-1 text-[11px] font-semibold tracking-wide text-faint uppercase">Найдены</p>
|
||||
{searching ? (
|
||||
<p className="px-2.5 py-2 text-sm text-muted">Поиск…</p>
|
||||
) : candidates.length === 0 ? (
|
||||
<p className="px-2.5 py-2 text-sm text-muted">Некого добавить</p>
|
||||
) : (
|
||||
<ul className="space-y-0.5">
|
||||
{candidates.map((user) => {
|
||||
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>
|
||||
<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" />
|
||||
{busy ? '…' : 'Добавить'}
|
||||
</button>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="px-2.5 pb-1 text-[11px] font-semibold tracking-wide text-faint uppercase">В группе</p>
|
||||
{loading ? (
|
||||
<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>
|
||||
) : (
|
||||
<ul className="space-y-0.5">
|
||||
{members.map((member) => {
|
||||
const isSelf = member.user.id === currentUser.id
|
||||
const canRemove = member.role !== 'owner' && (isOwner || isSelf)
|
||||
const busy = busyId === member.user.id
|
||||
return (
|
||||
<li
|
||||
key={member.user.id}
|
||||
className="flex items-center gap-3 rounded-xl px-2.5 py-2 transition hover:bg-surface-2/70"
|
||||
>
|
||||
<Avatar name={userTitle(member.user)} seed={member.user.id} size="md" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="flex items-center gap-2 truncate text-sm font-semibold text-fg">
|
||||
<span className="truncate">{userTitle(member.user)}</span>
|
||||
{member.role === 'owner' && (
|
||||
<span className="shrink-0 rounded-full border border-accent/40 bg-accent-ghost px-2 py-0.5 text-[10px] font-semibold text-accent">
|
||||
владелец
|
||||
</span>
|
||||
)}
|
||||
{isSelf && member.role !== 'owner' && (
|
||||
<span className="shrink-0 text-[10px] font-medium text-faint">вы</span>
|
||||
)}
|
||||
</p>
|
||||
<p className="truncate text-xs text-muted">@{member.user.username}</p>
|
||||
</div>
|
||||
{canRemove && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleRemove(member)}
|
||||
disabled={busy}
|
||||
aria-label={isSelf ? 'Покинуть группу' : 'Удалить участника'}
|
||||
title={isSelf ? 'Покинуть группу' : 'Удалить участника'}
|
||||
className="inline-flex h-8 w-8 items-center justify-center rounded-lg text-muted transition hover:bg-surface-2 hover:text-danger disabled:opacity-50"
|
||||
>
|
||||
<IconTrash className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import Avatar from './Avatar'
|
||||
import { IconAlert, IconCheck, IconCheckCheck } from './icons'
|
||||
import { formatTime, userTitle } from '../lib/format'
|
||||
import type { ClientMessage, User } from '../lib/types'
|
||||
import { IconAlert, IconCheck, IconCheckCheck, IconFile, IconSmile } from './icons'
|
||||
import { formatBytes, formatTime, userTitle } from '../lib/format'
|
||||
import type { ClientMessage, Media, User } from '../lib/types'
|
||||
|
||||
interface MessageBubbleProps {
|
||||
message: ClientMessage
|
||||
@@ -9,17 +10,93 @@ interface MessageBubbleProps {
|
||||
sender: User | null
|
||||
showAvatar: boolean
|
||||
showName: boolean
|
||||
currentUserId: number
|
||||
onRetry: (message: ClientMessage) => void
|
||||
onReact: (message: ClientMessage, emoji: string) => void
|
||||
}
|
||||
|
||||
export default function MessageBubble({ message, isOwn, sender, showAvatar, showName, onRetry }: MessageBubbleProps) {
|
||||
const QUICK_EMOJIS = ['👍', '❤️', '😂', '😮', '😢', '🔥'] as const
|
||||
|
||||
function MediaImage({ media }: { media: Media }) {
|
||||
const [src, setSrc] = useState(media.thumb_url ?? media.url)
|
||||
|
||||
useEffect(() => {
|
||||
if (!media.thumb_url || media.thumb_url === media.url) return
|
||||
const image = new Image()
|
||||
image.src = media.url
|
||||
image.onload = () => setSrc(media.url)
|
||||
return () => {
|
||||
image.onload = null
|
||||
}
|
||||
}, [media.thumb_url, media.url])
|
||||
|
||||
return (
|
||||
<a
|
||||
href={media.url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="block overflow-hidden rounded-xl"
|
||||
title={media.filename}
|
||||
>
|
||||
<img
|
||||
src={src}
|
||||
alt={media.filename}
|
||||
loading="lazy"
|
||||
className="max-h-72 w-full max-w-xs object-cover transition duration-300 hover:brightness-110"
|
||||
/>
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
||||
function MediaFile({ media }: { media: Media }) {
|
||||
return (
|
||||
<a
|
||||
href={media.url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="flex items-center gap-3 rounded-xl border border-line/70 bg-surface/60 px-3 py-2.5 transition hover:border-accent/50"
|
||||
>
|
||||
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-surface-3 text-accent">
|
||||
<IconFile className="h-5 w-5" />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-sm font-medium text-fg">{media.filename}</span>
|
||||
<span className="block text-[11px] text-muted">{formatBytes(media.size)}</span>
|
||||
</span>
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
||||
export default function MessageBubble({
|
||||
message,
|
||||
isOwn,
|
||||
sender,
|
||||
showAvatar,
|
||||
showName,
|
||||
currentUserId,
|
||||
onRetry,
|
||||
onReact,
|
||||
}: MessageBubbleProps) {
|
||||
const name = sender ? userTitle(sender) : `Пользователь #${message.sender_id}`
|
||||
const isSending = message.status === 'sending'
|
||||
const isError = message.status === 'error'
|
||||
|
||||
const grouped = useMemo(() => {
|
||||
const map = new Map<string, number[]>()
|
||||
for (const reaction of message.reactions ?? []) {
|
||||
const users = map.get(reaction.emoji) ?? []
|
||||
users.push(reaction.user_id)
|
||||
map.set(reaction.emoji, users)
|
||||
}
|
||||
return [...map.entries()]
|
||||
}, [message.reactions])
|
||||
|
||||
const media = message.media ?? null
|
||||
const isImage = media ? media.content_type.startsWith('image/') : false
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`flex w-full animate-msg-in items-end gap-2.5 px-3 sm:px-5 ${isOwn ? 'justify-end' : 'justify-start'}`}
|
||||
className={`group flex w-full animate-msg-in items-end gap-2.5 px-3 sm:px-5 ${isOwn ? 'justify-end' : 'justify-start'}`}
|
||||
>
|
||||
{!isOwn &&
|
||||
(showAvatar ? (
|
||||
@@ -29,53 +106,105 @@ export default function MessageBubble({ message, isOwn, sender, showAvatar, show
|
||||
))}
|
||||
|
||||
<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>
|
||||
{!isOwn && showName && <span className="mb-1 px-1 text-xs font-semibold text-muted">{name}</span>}
|
||||
|
||||
<div className="relative">
|
||||
<div
|
||||
className={`mt-1 flex items-center justify-end gap-1 text-[10.5px] ${
|
||||
isOwn ? 'text-white/70' : 'text-faint'
|
||||
className={`absolute -top-9 z-10 hidden items-center gap-0.5 rounded-full border border-line bg-panel/95 px-1.5 py-1 shadow-xl shadow-black/40 backdrop-blur group-hover:flex group-focus-within:flex ${
|
||||
isOwn ? 'right-0' : 'left-0'
|
||||
}`}
|
||||
>
|
||||
{message.edited_at && <span className="mr-auto pl-0.5">изменено</span>}
|
||||
<time dateTime={message.created_at}>{formatTime(message.created_at)}</time>
|
||||
{QUICK_EMOJIS.map((emoji) => (
|
||||
<button
|
||||
key={emoji}
|
||||
type="button"
|
||||
onClick={() => onReact(message, emoji)}
|
||||
aria-label={`Реакция ${emoji}`}
|
||||
className="inline-flex h-7 w-7 items-center justify-center rounded-full text-base transition hover:scale-110 hover:bg-surface-2"
|
||||
>
|
||||
{emoji}
|
||||
</button>
|
||||
))}
|
||||
<span className="mx-0.5 h-5 w-px bg-line" aria-hidden="true" />
|
||||
<span className="inline-flex h-7 w-7 items-center justify-center rounded-full text-muted" aria-hidden="true">
|
||||
<IconSmile className="h-4 w-4" />
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{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
|
||||
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(' ')}
|
||||
>
|
||||
{media && (
|
||||
<div className={message.body ? 'mb-2' : ''}>
|
||||
{isImage ? <MediaImage media={media} /> : <MediaFile media={media} />}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{message.body && <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>
|
||||
</div>
|
||||
|
||||
{grouped.length > 0 && (
|
||||
<div className={`mt-1 flex flex-wrap gap-1 ${isOwn ? 'justify-end' : 'justify-start'}`}>
|
||||
{grouped.map(([emoji, users]) => {
|
||||
const mine = users.includes(currentUserId)
|
||||
return (
|
||||
<button
|
||||
key={emoji}
|
||||
type="button"
|
||||
onClick={() => onReact(message, emoji)}
|
||||
aria-label={`Реакция ${emoji}: ${users.length}`}
|
||||
className={`inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-xs transition ${
|
||||
mine
|
||||
? 'border-accent/50 bg-accent-ghost text-accent'
|
||||
: 'border-line bg-surface-2 text-muted hover:border-accent/40 hover:text-fg'
|
||||
}`}
|
||||
>
|
||||
<span className="text-sm leading-none">{emoji}</span>
|
||||
{users.length > 1 && <span className="font-semibold">{users.length}</span>}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isError && <span className="mt-1 px-1 text-[11px] text-danger">Не отправлено</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -13,6 +13,7 @@ interface MessageListProps {
|
||||
error: string | null
|
||||
onLoadOlder: () => void
|
||||
onRetry: (message: ClientMessage) => void
|
||||
onReact: (message: ClientMessage, emoji: string) => void
|
||||
}
|
||||
|
||||
const GROUP_WINDOW_MINUTES = 5
|
||||
@@ -27,6 +28,7 @@ export default function MessageList({
|
||||
error,
|
||||
onLoadOlder,
|
||||
onRetry,
|
||||
onReact,
|
||||
}: MessageListProps) {
|
||||
const scrollRef = useRef<HTMLDivElement>(null)
|
||||
const firstIdRef = useRef<number | null>(null)
|
||||
@@ -135,7 +137,9 @@ export default function MessageList({
|
||||
sender={directory.get(message.sender_id) ?? null}
|
||||
showAvatar={newGroup}
|
||||
showName={newGroup}
|
||||
currentUserId={currentUser.id}
|
||||
onRetry={onRetry}
|
||||
onReact={onReact}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,17 +1,26 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import Avatar from './Avatar'
|
||||
import { IconClose, IconSearch, IconUserPlus } from './icons'
|
||||
import { IconClose, IconSearch, IconUserPlus, IconUsers } from './icons'
|
||||
import { api } from '../lib/api'
|
||||
import { userTitle } from '../lib/format'
|
||||
import type { Contact, User } from '../lib/types'
|
||||
|
||||
type DialogMode = 'dm' | 'group'
|
||||
|
||||
interface NewChatDialogProps {
|
||||
onClose: () => void
|
||||
onStartChat: (user: User) => Promise<void>
|
||||
onCreateGroup: (name: string, memberIds: number[]) => Promise<void>
|
||||
onContactsChanged: () => void
|
||||
}
|
||||
|
||||
export default function NewChatDialog({ onClose, onStartChat, onContactsChanged }: NewChatDialogProps) {
|
||||
export default function NewChatDialog({
|
||||
onClose,
|
||||
onStartChat,
|
||||
onCreateGroup,
|
||||
onContactsChanged,
|
||||
}: NewChatDialogProps) {
|
||||
const [mode, setMode] = useState<DialogMode>('dm')
|
||||
const [query, setQuery] = useState('')
|
||||
const [results, setResults] = useState<User[]>([])
|
||||
const [resultQuery, setResultQuery] = useState('')
|
||||
@@ -19,6 +28,9 @@ export default function NewChatDialog({ onClose, onStartChat, onContactsChanged
|
||||
const [loadingContacts, setLoadingContacts] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [busyId, setBusyId] = useState<number | null>(null)
|
||||
const [groupName, setGroupName] = useState('')
|
||||
const [selectedIds, setSelectedIds] = useState<Set<number>>(() => new Set())
|
||||
const [creating, setCreating] = useState(false)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
@@ -106,6 +118,32 @@ export default function NewChatDialog({ onClose, onStartChat, onContactsChanged
|
||||
onContactsChanged()
|
||||
})
|
||||
|
||||
const toggleMember = (userId: number) => {
|
||||
setSelectedIds((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(userId)) next.delete(userId)
|
||||
else next.add(userId)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const handleCreateGroup = async () => {
|
||||
const name = groupName.trim()
|
||||
if (!name || selectedIds.size === 0) return
|
||||
setCreating(true)
|
||||
setError(null)
|
||||
try {
|
||||
await onCreateGroup(name, [...selectedIds])
|
||||
onClose()
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Не удалось создать группу')
|
||||
} finally {
|
||||
setCreating(false)
|
||||
}
|
||||
}
|
||||
|
||||
const canCreate = groupName.trim().length > 0 && selectedIds.size > 0 && !creating
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-end justify-center bg-black/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" />
|
||||
@@ -120,7 +158,9 @@ export default function NewChatDialog({ onClose, onStartChat, onContactsChanged
|
||||
<h2 id="new-chat-title" className="font-display text-base font-semibold text-fg">
|
||||
Новый чат
|
||||
</h2>
|
||||
<p className="text-xs text-muted">Найдите человека или выберите из контактов</p>
|
||||
<p className="text-xs text-muted">
|
||||
{mode === 'dm' ? 'Найдите человека или выберите из контактов' : 'Соберите группу и добавьте участников'}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
@@ -133,6 +173,39 @@ export default function NewChatDialog({ onClose, onStartChat, onContactsChanged
|
||||
</div>
|
||||
|
||||
<div className="px-5 pt-4">
|
||||
<div className="mb-3 grid grid-cols-2 gap-1 rounded-xl border border-line bg-surface/60 p-1">
|
||||
{(['dm', 'group'] as const).map((value) => (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
onClick={() => setMode(value)}
|
||||
className={`rounded-lg px-3 py-1.5 text-xs font-semibold transition ${
|
||||
mode === value ? 'bg-accent-ghost text-accent ring-1 ring-accent/30' : 'text-muted hover:text-fg'
|
||||
}`}
|
||||
>
|
||||
{value === 'dm' ? 'Личный чат' : 'Группа'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{mode === 'group' && (
|
||||
<div className="relative mb-3">
|
||||
<IconUsers className="pointer-events-none absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 text-faint" />
|
||||
<label htmlFor="group-name" className="sr-only">
|
||||
Название группы
|
||||
</label>
|
||||
<input
|
||||
id="group-name"
|
||||
type="text"
|
||||
value={groupName}
|
||||
onChange={(event) => setGroupName(event.target.value)}
|
||||
placeholder="Название группы"
|
||||
maxLength={80}
|
||||
className="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>
|
||||
)}
|
||||
|
||||
<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">
|
||||
@@ -170,38 +243,80 @@ export default function NewChatDialog({ onClose, onStartChat, onContactsChanged
|
||||
{list.map((user) => {
|
||||
const isContact = contactIds.has(user.id)
|
||||
const busy = busyId === user.id
|
||||
const selected = selectedIds.has(user.id)
|
||||
return (
|
||||
<li key={user.id} 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"
|
||||
<li key={user.id}>
|
||||
<div
|
||||
className={`flex items-center gap-3 rounded-xl px-2.5 py-2 transition ${
|
||||
mode === 'group' && selected ? 'bg-accent-ghost ring-1 ring-accent/30' : 'hover:bg-surface-2/70'
|
||||
}`}
|
||||
>
|
||||
{busy ? '…' : 'Написать'}
|
||||
</button>
|
||||
{mode === 'group' && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleMember(user.id)}
|
||||
aria-pressed={selected}
|
||||
aria-label={selected ? 'Убрать из группы' : 'Добавить в группу'}
|
||||
className={`inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-md border transition ${
|
||||
selected ? 'border-accent bg-accent text-white' : 'border-line bg-surface text-transparent'
|
||||
}`}
|
||||
>
|
||||
<svg viewBox="0 0 24 24" className="h-3.5 w-3.5" fill="none" aria-hidden="true">
|
||||
<path d="m5 12.5 4.5 4.5L19 7" stroke="currentColor" strokeWidth="2.4" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
<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>
|
||||
{mode === 'dm' && (
|
||||
<>
|
||||
{!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>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{mode === 'group' && (
|
||||
<div className="flex items-center justify-between gap-3 border-t border-line/80 px-5 py-3">
|
||||
<span className="text-xs text-muted">
|
||||
Выбрано: <span className="font-semibold text-fg">{selectedIds.size}</span>
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleCreateGroup()}
|
||||
disabled={!canCreate}
|
||||
className="rounded-xl bg-gradient-to-br from-accent to-accent-strong px-4 py-2 text-sm font-semibold text-white transition enabled:hover:brightness-110 disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
{creating ? 'Создание…' : 'Создать группу'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import Avatar from './Avatar'
|
||||
import Avatar, { GroupAvatar } from './Avatar'
|
||||
import { IconLogout, IconPlus, IconSearch } from './icons'
|
||||
import { formatListTime, peerForConversation, userTitle } from '../lib/format'
|
||||
import type { Conversation, User } from '../lib/types'
|
||||
@@ -21,9 +21,15 @@ interface SidebarProps {
|
||||
onLogout: () => void
|
||||
}
|
||||
|
||||
function previewOf(conversation: Conversation, currentUserId: number): string {
|
||||
function previewOf(conversation: Conversation, currentUserId: number, directory: Map<number, User>): string {
|
||||
const last = conversation.last_message
|
||||
if (!last) return 'Нет сообщений'
|
||||
if (last.media && !last.body) return last.sender_id === currentUserId ? 'Вы: вложение' : 'Вложение'
|
||||
if (conversation.type === 'group' && last.sender_id !== currentUserId) {
|
||||
const sender = directory.get(last.sender_id)
|
||||
const prefix = sender ? `${userTitle(sender)}: ` : ''
|
||||
return `${prefix}${last.body}`
|
||||
}
|
||||
const prefix = last.sender_id === currentUserId ? 'Вы: ' : ''
|
||||
return `${prefix}${last.body}`
|
||||
}
|
||||
@@ -50,9 +56,20 @@ export default function Sidebar({
|
||||
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) }
|
||||
const isGroup = conversation.type === 'group'
|
||||
const peer = isGroup ? null : peerForConversation(conversation, peers, directory, currentUser.id)
|
||||
const title = isGroup
|
||||
? conversation.name || `Группа #${conversation.id}`
|
||||
: peer
|
||||
? userTitle(peer)
|
||||
: `Чат #${conversation.id}`
|
||||
return {
|
||||
conversation,
|
||||
peer,
|
||||
isGroup,
|
||||
title,
|
||||
preview: previewOf(conversation, currentUser.id, directory),
|
||||
}
|
||||
})
|
||||
.filter((item) => !term || item.title.toLowerCase().includes(term) || item.preview.toLowerCase().includes(term))
|
||||
}, [conversations, query, peers, directory, currentUser.id])
|
||||
@@ -152,7 +169,7 @@ export default function Sidebar({
|
||||
<ul className="space-y-0.5">
|
||||
{!loading &&
|
||||
!error &&
|
||||
items.map(({ conversation, peer, title, preview }) => {
|
||||
items.map(({ conversation, peer, isGroup, title, preview }) => {
|
||||
const selected = conversation.id === selectedId
|
||||
const isTyping = typing[conversation.id] === true
|
||||
const unread = conversation.unread_count ?? 0
|
||||
@@ -168,12 +185,16 @@ export default function Sidebar({
|
||||
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}
|
||||
/>
|
||||
{isGroup ? (
|
||||
<GroupAvatar name={title} seed={conversation.id} size="lg" />
|
||||
) : (
|
||||
<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'}`}>
|
||||
|
||||
@@ -108,3 +108,61 @@ export function IconUserPlus({ className }: IconProps) {
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function IconUsers({ className }: IconProps) {
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||
<circle cx="9" cy="8.5" r="3.2" stroke="currentColor" strokeWidth="1.6" />
|
||||
<path d="M3.5 19a5.5 5.5 0 0 1 11 0" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" />
|
||||
<path d="M16 5.6a3.2 3.2 0 0 1 0 5.8M17.5 13.6A5.5 5.5 0 0 1 20.5 19" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function IconPaperclip({ className }: IconProps) {
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||
<path
|
||||
d="M8.5 12.5 14 7a3 3 0 0 1 4.2 4.2l-6.6 6.6a5 5 0 0 1-7.1-7.1l6.4-6.4"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.7"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function IconFile({ className }: IconProps) {
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||
<path
|
||||
d="M6 3.5h7l5 5v12a1 1 0 0 1-1 1H6a1 1 0 0 1-1-1v-16a1 1 0 0 1 1-1Z"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.6"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path d="M13 3.5v5h5" stroke="currentColor" strokeWidth="1.6" strokeLinejoin="round" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function IconTrash({ className }: IconProps) {
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||
<path d="M5 7h14M10 7V5.5A1.5 1.5 0 0 1 11.5 4h1A1.5 1.5 0 0 1 14 5.5V7" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" />
|
||||
<path d="M6.5 7l.8 12a1.5 1.5 0 0 0 1.5 1.4h6.4a1.5 1.5 0 0 0 1.5-1.4l.8-12" stroke="currentColor" strokeWidth="1.6" strokeLinejoin="round" />
|
||||
<path d="M10.5 11v6M13.5 11v6" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function IconSmile({ 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.6" />
|
||||
<path d="M8.5 14.2a4.2 4.2 0 0 0 7 0" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" />
|
||||
<path d="M9.3 9.5h.01M14.7 9.5h.01" stroke="currentColor" strokeWidth="2.1" strokeLinecap="round" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ export interface UseConversationsResult {
|
||||
error: string | null
|
||||
reload: () => Promise<void>
|
||||
add: (conversation: Conversation) => void
|
||||
remove: (id: number) => void
|
||||
patch: (id: number, updater: (conversation: Conversation) => Conversation) => void
|
||||
}
|
||||
|
||||
@@ -71,5 +72,9 @@ export function useConversations(enabled = true): UseConversationsResult {
|
||||
setConversations((prev) => sortConversations(prev.map((item) => (item.id === id ? updater(item) : item))))
|
||||
}, [])
|
||||
|
||||
return { conversations, loading, error, reload, add, patch }
|
||||
const remove = useCallback((id: number) => {
|
||||
setConversations((prev) => prev.filter((item) => item.id !== id))
|
||||
}, [])
|
||||
|
||||
return { conversations, loading, error, reload, add, remove, patch }
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ export interface UseMessagesResult {
|
||||
error: string | null
|
||||
loadOlder: () => Promise<void>
|
||||
upsert: (message: Message, tempId?: string) => void
|
||||
patchMessage: (id: number, updater: (message: ClientMessage) => ClientMessage) => void
|
||||
addOptimistic: (message: ClientMessage) => void
|
||||
markSending: (tempId: string) => void
|
||||
failOptimistic: (tempId: string) => void
|
||||
@@ -111,6 +112,10 @@ export function useMessages(conversationId: number | null, currentUserId: number
|
||||
})
|
||||
}, [])
|
||||
|
||||
const patchMessage = useCallback((id: number, updater: (message: ClientMessage) => ClientMessage) => {
|
||||
setMessages((prev) => prev.map((item) => (item.id === id ? updater(item) : item)))
|
||||
}, [])
|
||||
|
||||
const addOptimistic = useCallback((message: ClientMessage) => {
|
||||
setMessages((prev) => {
|
||||
if (message.temp_id && prev.some((item) => item.temp_id === message.temp_id)) return prev
|
||||
@@ -147,6 +152,7 @@ export function useMessages(conversationId: number | null, currentUserId: number
|
||||
error,
|
||||
loadOlder,
|
||||
upsert,
|
||||
patchMessage,
|
||||
addOptimistic,
|
||||
markSending,
|
||||
failOptimistic,
|
||||
|
||||
+83
-4
@@ -1,4 +1,4 @@
|
||||
import type { Contact, Conversation, Message, SendMessageResult, User } from './types'
|
||||
import type { Contact, Conversation, Media, Member, Message, SendMessageResult, User } from './types'
|
||||
|
||||
const API_BASE = '/api/v1'
|
||||
|
||||
@@ -98,6 +98,29 @@ async function request<T>(path: string, options: RequestOptions = {}): Promise<T
|
||||
return readJson<T>(res)
|
||||
}
|
||||
|
||||
async function requestForm<T>(path: string, form: FormData, authRetry = true): Promise<T> {
|
||||
const res = await fetch(`${API_BASE}${path}`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
body: form,
|
||||
})
|
||||
|
||||
if (res.status === 401 && authRetry) {
|
||||
try {
|
||||
await refreshSession()
|
||||
} catch {
|
||||
throw new UnauthorizedError()
|
||||
}
|
||||
return requestForm<T>(path, form, false)
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
throw new ApiError(await readError(res), res.status)
|
||||
}
|
||||
|
||||
return readJson<T>(res)
|
||||
}
|
||||
|
||||
export interface ApiClient {
|
||||
register(username: string, displayName: string, password: string): Promise<User>
|
||||
login(username: string, password: string): Promise<User>
|
||||
@@ -110,8 +133,20 @@ export interface ApiClient {
|
||||
removeContact(userId: number): Promise<void>
|
||||
listConversations(): Promise<Conversation[]>
|
||||
createDM(userId: number): Promise<Conversation>
|
||||
createGroup(name: string, memberIds: number[]): Promise<Conversation>
|
||||
listMembers(conversationId: number): Promise<Member[]>
|
||||
addMember(conversationId: number, userId: number): Promise<void>
|
||||
removeMember(conversationId: number, userId: number): Promise<void>
|
||||
listMessages(conversationId: number, before?: number, limit?: number): Promise<Message[]>
|
||||
sendMessage(conversationId: number, body: string, tempId: string): Promise<SendMessageResult>
|
||||
sendMessage(
|
||||
conversationId: number,
|
||||
body: string,
|
||||
tempId: string,
|
||||
mediaId?: number,
|
||||
): Promise<SendMessageResult>
|
||||
addReaction(conversationId: number, messageId: number, emoji: string): Promise<Message>
|
||||
removeReaction(conversationId: number, messageId: number, emoji: string): Promise<Message>
|
||||
uploadMedia(file: File): Promise<Media>
|
||||
}
|
||||
|
||||
export const api: ApiClient = {
|
||||
@@ -167,17 +202,61 @@ export const api: ApiClient = {
|
||||
return request<Conversation>('/conversations', { method: 'POST', body: { user_id: userId } })
|
||||
},
|
||||
|
||||
createGroup(name, memberIds) {
|
||||
return request<Conversation>('/conversations', {
|
||||
method: 'POST',
|
||||
body: { type: 'group', name, member_ids: memberIds },
|
||||
})
|
||||
},
|
||||
|
||||
listMembers(conversationId) {
|
||||
return request<Member[]>(`/conversations/${conversationId}/members`)
|
||||
},
|
||||
|
||||
addMember(conversationId, userId) {
|
||||
return request<void>(`/conversations/${conversationId}/members`, {
|
||||
method: 'POST',
|
||||
body: { user_id: userId },
|
||||
})
|
||||
},
|
||||
|
||||
removeMember(conversationId, userId) {
|
||||
return request<void>(`/conversations/${conversationId}/members/${userId}`, { method: 'DELETE' })
|
||||
},
|
||||
|
||||
listMessages(conversationId, before, limit = 50) {
|
||||
const params = new URLSearchParams({ limit: String(limit) })
|
||||
if (before !== undefined) params.set('before', String(before))
|
||||
return request<Message[]>(`/conversations/${conversationId}/messages?${params.toString()}`)
|
||||
},
|
||||
|
||||
async sendMessage(conversationId, body, tempId) {
|
||||
async sendMessage(conversationId, body, tempId, mediaId) {
|
||||
const payload: { body: string; temp_id: string; media_id?: number } = { body, temp_id: tempId }
|
||||
if (mediaId !== undefined) payload.media_id = mediaId
|
||||
const message = await request<Message>(`/conversations/${conversationId}/messages`, {
|
||||
method: 'POST',
|
||||
body: { body, temp_id: tempId },
|
||||
body: payload,
|
||||
})
|
||||
return { temp_id: tempId, message }
|
||||
},
|
||||
|
||||
addReaction(conversationId, messageId, emoji) {
|
||||
return request<Message>(`/conversations/${conversationId}/messages/${messageId}/reactions`, {
|
||||
method: 'PUT',
|
||||
body: { emoji },
|
||||
})
|
||||
},
|
||||
|
||||
removeReaction(conversationId, messageId, emoji) {
|
||||
return request<Message>(
|
||||
`/conversations/${conversationId}/messages/${messageId}/reactions/${encodeURIComponent(emoji)}`,
|
||||
{ method: 'DELETE' },
|
||||
)
|
||||
},
|
||||
|
||||
uploadMedia(file) {
|
||||
const form = new FormData()
|
||||
form.append('file', file)
|
||||
return requestForm<Media>('/media', form)
|
||||
},
|
||||
}
|
||||
|
||||
@@ -76,6 +76,15 @@ export function minutesBetween(a: string, b: string): number {
|
||||
return Math.abs(first.getTime() - second.getTime()) / 60000
|
||||
}
|
||||
|
||||
export function formatBytes(bytes: number): string {
|
||||
if (!Number.isFinite(bytes) || bytes <= 0) return '0 Б'
|
||||
const units = ['Б', 'КБ', 'МБ', 'ГБ'] as const
|
||||
const index = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1)
|
||||
const value = bytes / 1024 ** index
|
||||
const rounded = index === 0 || value >= 10 ? Math.round(value) : Number(value.toFixed(1))
|
||||
return `${rounded} ${units[index]}`
|
||||
}
|
||||
|
||||
export function peerForConversation(
|
||||
conversation: Conversation,
|
||||
peers: Record<number, number>,
|
||||
|
||||
+90
-1
@@ -11,11 +11,27 @@ export interface Contact {
|
||||
added_at: string
|
||||
}
|
||||
|
||||
export interface Reaction {
|
||||
emoji: string
|
||||
user_id: number
|
||||
}
|
||||
|
||||
export interface Media {
|
||||
id: number
|
||||
filename: string
|
||||
content_type: string
|
||||
size: number
|
||||
url: string
|
||||
thumb_url?: string | null
|
||||
}
|
||||
|
||||
export interface Message {
|
||||
id: number
|
||||
conversation_id: number
|
||||
sender_id: number
|
||||
body: string
|
||||
reactions: Reaction[]
|
||||
media?: Media | null
|
||||
created_at: string
|
||||
edited_at?: string | null
|
||||
deleted_at?: string | null
|
||||
@@ -32,11 +48,21 @@ export interface ClientMessage extends Message {
|
||||
export interface Conversation {
|
||||
id: number
|
||||
type: string
|
||||
name: string
|
||||
avatar: string | null
|
||||
created_at: string
|
||||
last_message?: Message | null
|
||||
unread_count?: number
|
||||
}
|
||||
|
||||
export type MemberRole = 'owner' | 'member'
|
||||
|
||||
export interface Member {
|
||||
user: User
|
||||
role: MemberRole
|
||||
joined_at: string
|
||||
}
|
||||
|
||||
export interface SendMessageResult {
|
||||
temp_id: string
|
||||
message: Message
|
||||
@@ -51,6 +77,9 @@ export interface WsMessageCreated {
|
||||
sender_id: number
|
||||
body: string
|
||||
created_at: string
|
||||
reactions?: Reaction[]
|
||||
media_id?: number
|
||||
media?: Media | null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -80,4 +109,64 @@ export interface WsRead {
|
||||
}
|
||||
}
|
||||
|
||||
export type WsEvent = WsMessageCreated | WsTyping | WsPresence | WsRead
|
||||
export interface WsConversationAdded {
|
||||
type: 'conversation.added'
|
||||
data: {
|
||||
conversation_id: number
|
||||
}
|
||||
}
|
||||
|
||||
export interface WsConversationRemoved {
|
||||
type: 'conversation.removed'
|
||||
data: {
|
||||
conversation_id: number
|
||||
}
|
||||
}
|
||||
|
||||
export interface WsMemberAdded {
|
||||
type: 'member.added'
|
||||
data: {
|
||||
conversation_id: number
|
||||
user_id: number
|
||||
}
|
||||
}
|
||||
|
||||
export interface WsMemberRemoved {
|
||||
type: 'member.removed'
|
||||
data: {
|
||||
conversation_id: number
|
||||
user_id: number
|
||||
}
|
||||
}
|
||||
|
||||
export interface WsReactionAdded {
|
||||
type: 'reaction.added'
|
||||
data: {
|
||||
conversation_id: number
|
||||
message_id: number
|
||||
user_id: number
|
||||
emoji: string
|
||||
}
|
||||
}
|
||||
|
||||
export interface WsReactionRemoved {
|
||||
type: 'reaction.removed'
|
||||
data: {
|
||||
conversation_id: number
|
||||
message_id: number
|
||||
user_id: number
|
||||
emoji: string
|
||||
}
|
||||
}
|
||||
|
||||
export type WsEvent =
|
||||
| WsMessageCreated
|
||||
| WsTyping
|
||||
| WsPresence
|
||||
| WsRead
|
||||
| WsConversationAdded
|
||||
| WsConversationRemoved
|
||||
| WsMemberAdded
|
||||
| WsMemberRemoved
|
||||
| WsReactionAdded
|
||||
| WsReactionRemoved
|
||||
|
||||
+12
-1
@@ -14,7 +14,18 @@ interface OutgoingRead {
|
||||
data: { conversation_id: number; message_id: number }
|
||||
}
|
||||
|
||||
const EVENT_TYPES: readonly WsEventType[] = ['message.created', 'typing', 'presence', 'read']
|
||||
const EVENT_TYPES: readonly WsEventType[] = [
|
||||
'message.created',
|
||||
'typing',
|
||||
'presence',
|
||||
'read',
|
||||
'conversation.added',
|
||||
'conversation.removed',
|
||||
'member.added',
|
||||
'member.removed',
|
||||
'reaction.added',
|
||||
'reaction.removed',
|
||||
]
|
||||
|
||||
function isWsEvent(value: unknown): value is WsEvent {
|
||||
if (typeof value !== 'object' || value === null) return false
|
||||
|
||||
@@ -7,7 +7,7 @@ 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'
|
||||
import type { ClientMessage, Media, Member, Message, User, WsEvent } from '../lib/types'
|
||||
|
||||
interface ChatAppProps {
|
||||
user: User
|
||||
@@ -22,8 +22,9 @@ export default function ChatApp({ user }: ChatAppProps) {
|
||||
const [typing, setTyping] = useState<Record<number, boolean>>({})
|
||||
const [dialogOpen, setDialogOpen] = useState(false)
|
||||
const [focusTick, setFocusTick] = useState(0)
|
||||
const [membersVersion, setMembersVersion] = useState(0)
|
||||
|
||||
const { conversations, loading, error, reload, add, patch } = useConversations()
|
||||
const { conversations, loading, error, reload, add, remove, patch } = useConversations()
|
||||
const {
|
||||
messages,
|
||||
loading: messagesLoading,
|
||||
@@ -32,6 +33,7 @@ export default function ChatApp({ user }: ChatAppProps) {
|
||||
error: messagesError,
|
||||
loadOlder,
|
||||
upsert,
|
||||
patchMessage,
|
||||
addOptimistic,
|
||||
markSending,
|
||||
failOptimistic,
|
||||
@@ -96,8 +98,16 @@ export default function ChatApp({ user }: ChatAppProps) {
|
||||
(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 }
|
||||
const { temp_id, conversation_id, message_id, sender_id, body, created_at, reactions, media } = event.data
|
||||
const message: Message = {
|
||||
id: message_id,
|
||||
conversation_id,
|
||||
sender_id,
|
||||
body,
|
||||
created_at,
|
||||
reactions: reactions ?? [],
|
||||
media: media ?? null,
|
||||
}
|
||||
if (conversation_id === selectedIdRef.current || sender_id === user.id) {
|
||||
upsert(message, temp_id)
|
||||
}
|
||||
@@ -146,23 +156,64 @@ export default function ChatApp({ user }: ChatAppProps) {
|
||||
if (conversation_id === selectedIdRef.current) markReadUpTo(message_id)
|
||||
break
|
||||
}
|
||||
case 'reaction.added': {
|
||||
const { conversation_id, message_id, user_id, emoji } = event.data
|
||||
if (conversation_id !== selectedIdRef.current) break
|
||||
patchMessage(message_id, (message) => {
|
||||
if (message.reactions.some((reaction) => reaction.emoji === emoji && reaction.user_id === user_id)) {
|
||||
return message
|
||||
}
|
||||
return { ...message, reactions: [...message.reactions, { emoji, user_id }] }
|
||||
})
|
||||
break
|
||||
}
|
||||
case 'reaction.removed': {
|
||||
const { conversation_id, message_id, user_id, emoji } = event.data
|
||||
if (conversation_id !== selectedIdRef.current) break
|
||||
patchMessage(message_id, (message) => ({
|
||||
...message,
|
||||
reactions: message.reactions.filter(
|
||||
(reaction) => !(reaction.emoji === emoji && reaction.user_id === user_id),
|
||||
),
|
||||
}))
|
||||
break
|
||||
}
|
||||
case 'member.added':
|
||||
case 'member.removed': {
|
||||
if (event.data.conversation_id === selectedIdRef.current) {
|
||||
setMembersVersion((version) => version + 1)
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'conversation.added': {
|
||||
void reload()
|
||||
break
|
||||
}
|
||||
case 'conversation.removed': {
|
||||
const { conversation_id } = event.data
|
||||
remove(conversation_id)
|
||||
if (selectedIdRef.current === conversation_id) setSelectedId(null)
|
||||
break
|
||||
}
|
||||
}
|
||||
},
|
||||
[markReadUpTo, patch, reload, upsert, user.id],
|
||||
[markReadUpTo, patch, patchMessage, reload, remove, upsert, user.id],
|
||||
)
|
||||
|
||||
const realtime = useRealtime(handleEvent)
|
||||
|
||||
const sendMessage = useCallback(
|
||||
async (conversationId: number, body: string) => {
|
||||
async (conversationId: number, body: string, media: Media | null) => {
|
||||
const text = body.trim()
|
||||
if (!text) return
|
||||
if (!text && !media) return
|
||||
const tempId = crypto.randomUUID()
|
||||
const optimistic: ClientMessage = {
|
||||
id: -Date.now(),
|
||||
conversation_id: conversationId,
|
||||
sender_id: user.id,
|
||||
body: text,
|
||||
reactions: [],
|
||||
media,
|
||||
created_at: new Date().toISOString(),
|
||||
temp_id: tempId,
|
||||
status: 'sending',
|
||||
@@ -170,7 +221,7 @@ export default function ChatApp({ user }: ChatAppProps) {
|
||||
addOptimistic(optimistic)
|
||||
patch(conversationId, (conversation) => ({ ...conversation, last_message: optimistic, unread_count: 0 }))
|
||||
try {
|
||||
const result = await api.sendMessage(conversationId, text, tempId)
|
||||
const result = await api.sendMessage(conversationId, text, tempId, media?.id)
|
||||
upsert(result.message, tempId)
|
||||
patch(conversationId, (conversation) => ({
|
||||
...conversation,
|
||||
@@ -190,7 +241,7 @@ export default function ChatApp({ user }: ChatAppProps) {
|
||||
if (!tempId) return
|
||||
markSending(tempId)
|
||||
try {
|
||||
const result = await api.sendMessage(message.conversation_id, message.body, tempId)
|
||||
const result = await api.sendMessage(message.conversation_id, message.body, tempId, message.media?.id)
|
||||
upsert(result.message, tempId)
|
||||
patch(message.conversation_id, (conversation) => ({
|
||||
...conversation,
|
||||
@@ -204,6 +255,22 @@ export default function ChatApp({ user }: ChatAppProps) {
|
||||
[failOptimistic, markSending, patch, upsert],
|
||||
)
|
||||
|
||||
const handleReaction = useCallback(
|
||||
async (message: ClientMessage, emoji: string) => {
|
||||
if (message.id <= 0) return
|
||||
const mine = message.reactions.some((reaction) => reaction.emoji === emoji && reaction.user_id === user.id)
|
||||
try {
|
||||
const updated = mine
|
||||
? await api.removeReaction(message.conversation_id, message.id, emoji)
|
||||
: await api.addReaction(message.conversation_id, message.id, emoji)
|
||||
upsert(updated)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
},
|
||||
[upsert, user.id],
|
||||
)
|
||||
|
||||
const sendTyping = useCallback(
|
||||
(conversationId: number, isTyping: boolean) => {
|
||||
realtime.sendTyping(conversationId, isTyping)
|
||||
@@ -233,6 +300,23 @@ export default function ChatApp({ user }: ChatAppProps) {
|
||||
[add, loadContacts, rememberUser],
|
||||
)
|
||||
|
||||
const handleCreateGroup = useCallback(
|
||||
async (name: string, memberIds: number[]) => {
|
||||
const conversation = await api.createGroup(name, memberIds)
|
||||
add(conversation)
|
||||
setSelectedId(conversation.id)
|
||||
setMembersVersion((version) => version + 1)
|
||||
},
|
||||
[add],
|
||||
)
|
||||
|
||||
const handleMembersLoaded = useCallback(
|
||||
(members: Member[]) => {
|
||||
members.forEach((member) => rememberUser(member.user))
|
||||
},
|
||||
[rememberUser],
|
||||
)
|
||||
|
||||
const lastMessage = messages.length > 0 ? messages[messages.length - 1] : null
|
||||
const lastMessageId = lastMessage && lastMessage.id > 0 ? lastMessage.id : null
|
||||
|
||||
@@ -264,6 +348,7 @@ export default function ChatApp({ user }: ChatAppProps) {
|
||||
/>
|
||||
|
||||
<ChatWindow
|
||||
key={selectedId ?? 'empty'}
|
||||
className={selectedId === null ? 'hidden md:flex' : 'flex'}
|
||||
conversation={conversations.find((conversation) => conversation.id === selectedId) ?? null}
|
||||
currentUser={user}
|
||||
@@ -276,11 +361,15 @@ export default function ChatApp({ user }: ChatAppProps) {
|
||||
loadingOlder={loadingOlder}
|
||||
hasMore={hasMore}
|
||||
error={messagesError}
|
||||
membersVersion={membersVersion}
|
||||
onBack={() => setSelectedId(null)}
|
||||
onSend={sendMessage}
|
||||
onTyping={sendTyping}
|
||||
onLoadOlder={() => void loadOlder()}
|
||||
onRetry={resendMessage}
|
||||
onReact={(message, emoji) => void handleReaction(message, emoji)}
|
||||
onMembersChanged={() => setMembersVersion((version) => version + 1)}
|
||||
onMembersLoaded={handleMembersLoaded}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -288,6 +377,7 @@ export default function ChatApp({ user }: ChatAppProps) {
|
||||
<NewChatDialog
|
||||
onClose={() => setDialogOpen(false)}
|
||||
onStartChat={handleStartChat}
|
||||
onCreateGroup={handleCreateGroup}
|
||||
onContactsChanged={() => void loadContacts()}
|
||||
/>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user