Создание чата в админ-панели Admiral


2. Архитектура чата
3. Основная страница чата
4. Управление WebSocket-соединением с SocketContext
5. Управление состоянием чата с ChatContext
6. Компоненты пользовательского интерфейса: Sidebar + Panel + Input
7. Стилизация
8. Добавление уведомлений прямо в ChatContext
9. Заключение
Почему Admiral идеален для кастомных решений
Архитектура чата
- ChatPage – основная страница чата;
- ChatSidebar – список диалогов с превью;
- ChatPanel – область отображения выбранного чата;
- MessageFeed – лента сообщений;
- MessageInput – поле ввода с поддержкой файлов.
- SocketContext – управление WebSocket соединениями;
- ChatContext – управление состоянием диалогов и сообщений.
Основная страница чата
// pages/chat/index.tsx
import ChatPage from '@/src/crud/chat'
export default ChatPage
// src/crud/chat/index.tsx
import React from 'react'
import { Card } from '@devfamily/admiral'
import { usePermissions, usePermissionsRedirect } from '@devfamily/admiral'
import { SocketProvider } from './contexts/SocketContext'
import { ChatProvider } from './contexts/ChatContext'
import ChatSidebar from './components/ChatSidebar'
import ChatPanel from './components/ChatPanel'
import styles from './Chat.module.css'
export default function ChatPage() {
const { permissions, loaded, isAdmin } = usePermissions()
const identityPermissions = permissions?.chat?.chat
usePermissionsRedirect({ identityPermissions, isAdmin, loaded })
return (
<SocketProvider>
<ChatProvider>
<Card className={styles.page}>
<PageTitle title="Корпоративный чат" />
<div className={styles.chat}>
<ChatSidebar />
<ChatPanel />
</div>
</Card>
</ChatProvider>
</SocketProvider>
)
}
- usePermissions() – получает текущие права пользователя и может быть использован для условного рендеринга UI;
- usePermissionsRedirect() – автоматически перенаправляет пользователя, если у него нет нужных прав, что особенно полезно в админ-панелях;
- Card - компонент Admiral для визуального оформления секции. Подробнее ознакомиться с компонентом можно на демо-стенде: https://admiral.dev.family/components/card;
- PageTitle - компонент Admiral для единообразного отображения заголовков страниц. Демо: https://admiral.dev.family/components/typography.
Управление WebSocket-соединением с SocketContext
// src/crud/chat/SocketContext.tsx
import React from 'react'
import { Centrifuge } from 'centrifuge'
import { createContext, ReactNode, useContext, useEffect, useRef, useState } from 'react'
import { useGetIdentity } from '@devfamily/admiral'
const SocketContext = createContext(null)
export const SocketProvider = ({ children }: { children: ReactNode }) => {
const { identity: user } = useGetIdentity()
const [lastMessage, setLastMessage] = useState(null)
const centrifugeRef = useRef(null)
const subscribedRef = useRef(false)
useEffect(() => {
if (!user?.ws_token) return
const WS_URL = import.meta.env.VITE_WS_URL
if (!WS_URL) {
console.error('❌ Missing VITE_WS_URL in env')
return
}
const centrifuge = new Centrifuge(WS_URL, {
token: user.ws_token, // Инициализация WebSocket по токену
})
centrifugeRef.current = centrifuge
centrifugeRef.current.connect()
// Подписка на канал чата
const sub = centrifugeRef.current.newSubscription(`admin_chat`)
sub.on('publication', function (ctx: any) {
setLastMessage(ctx.data);
}).subscribe()
// Очистка при размонтировании
return () => {
subscribedRef.current = false
centrifuge.disconnect()
}
}, [user?.ws_token])
return (
<SocketContext.Provider value={{ lastMessage, centrifuge: centrifugeRef.current }}>
{children}
</SocketContext.Provider>
)
}
export const useSocket = () => {
const ctx = useContext(SocketContext)
if (!ctx) throw new Error('useSocket must be used within SocketProvider')
return ctx
}
- Centrifuge – библиотека для работы с WebSockets;
- useGetIdentity() - хук Admiral, который получает информацию о текущем пользователе, включая ws_token, необходимый для аутентификации WebSocket-соединения;
- useEffect – инициализирует и управляет жизненным циклом WebSocket-соединения. Важно отметить очистку соединения (centrifuge.disconnect()) при размонтировании компонента, чтобы избежать утечек памяти и нежелательных соединений;
- Подписка на канал your_channel_name – все сообщения, относящиеся к административному чату, будут приходить по этому каналу;
- Обработчик on('publication') – получает новые сообщения и события (например, message_read или new_message) и обновляет состояние lastMessage.
Управление состоянием чата с ChatContext
// src/crud/chat/ChatContext.tsx
import React, { useRef } from "react";
import {
createContext,
useContext,
useEffect,
useState,
useRef,
useCallback,
} from "react";
import { useSocket } from "./SocketContext";
import { useUrlState } from "@devfamily/admiral";
import api from "../api";
const ChatContext = createContext(null);
export const ChatProvider = ({ children }) => {
const { lastMessage } = useSocket();
const [dialogs, setDialogs] = useState([]);
const [messages, setMessages] = useState([]);
const [selectedDialog, setSelectedDialog] = useState(null);
const [urlState] = useUrlState();
const { client_id } = urlState;
const fetchDialogs = useCallback(async () => {
const res = await api.dialogs();
setDialogs(res.data || []);
}, []);
const fetchMessages = useCallback(async (id) => {
const res = await api.messages(id);
setMessages(res.data || []);
}, []);
useEffect(() => {
fetchMessages(client_id);
}, [fetchMessages, client_id]);
useEffect(() => {
fetchDialogs();
}, [fetchDialogs]);
useEffect(() => {
if (!lastMessage) return;
fetchDialogs();
setMessages((prev) => [...prev, lastMessage.data]);
}, [lastMessage]);
const sendMessage = useCallback(
async (value, onSuccess, onError) => {
try {
const res = await api.send(value);
if (res?.data) setMessages((prev) => [...prev, res.data]);
fetchDialogs();
onSuccess();
} catch (err) {
onError(err);
}
},
[messages]
);
// В этом контексте вы можете расширить логику:
// - Отмечать сообщения как прочитанные (api.read())
// - Группировать сообщения по дате и т.п.
return (
<ChatContext.Provider
value={{
dialogs,
messages: groupMessagesByDate(messages),
selectedDialog,
setSelectedDialog,
sendMessage,
}}
>
{children}
</ChatContext.Provider>
);
};
export const useChat = () => {
const ctx = useContext(ChatContext);
if (!ctx) throw new Error("useChat must be used within ChatProvider");
return ctx;
};
- useUrlState – хук Admiral для синхронизации состояния с URL;
- useSocket() – получает последние сообщения из SocketContext для real-time обновлений.
- fetchMessages и fetchDialogs – асинхронные функции для получения сообщений и списка диалогов с сервера;
- useEffect для lastMessage – обрабатывает новые сообщения, приходящие через WebSocket;
- sendMessage – функция для отправки сообщений на сервер, которая также обновляет локальное состояние чата и список диалогов.
Пример API-клиента
// src/crud/chat/api.ts
import _ from '../../config/request'
import { apiUrl } from '@/src/config/api'
const api = {
dialogs: () => _.get(`${apiUrl}/chat/dialogs`)(),
messages: (id) => _.get(`${apiUrl}/chat/messages/${id}`)(),
send: (data) => _.postFD(`${apiUrl}/chat/send`)({ data }),
read: (data) => _.post(`${apiUrl}/chat/read`)({ data }),
}
export default api
Компоненты пользовательского интерфейса: Sidebar + Panel + Input
4.1. ChatSidebar – Список диалогов
// src/crud/chat/components/ChatSidebar.tsx
import React from "react";
import styles from "./ChatSidebar.module.scss";
import ChatSidebarItem from "../ChatSidebarItem/ChatSidebarItem";
import { useChat } from "../../model/ChatContext";
function ChatSidebar({}) {
const { dialogs } = useChat();
if (!dialogs.length) {
return (
<div className={styles.empty}>
<span>Нет активных диалогов</span>
</div>
);
}
return <div className={styles.list}>
{dialogs.map((item) => (
<ChatSidebarItem key={item.id} data={item} />
))}
</div>
}
export default ChatSidebar;
4.2. ChatSidebarItem – Элемент списка диалогов
// src/crud/chat/components/ChatSidebarItem.tsx
import React from "react";
import { Badge } from '@devfamily/admiral'
import dayjs from "dayjs";
import { BsCheck2, BsCheck2All } from "react-icons/bs";
import styles from "./ChatSidebarItem.module.scss";
function ChatSidebarItem({ data }) {
const { client_name, client_id, last_message, last_message_ } = data;
const [urlState, setUrlState] = useUrlState();
const { client_id } = urlState;
const { setSelectedDialog } = useChat();
const onSelectDialog = useCallback(() => {
setUrlState({ client_id: client.id });
setSelectedDialog(data);
}, [order.id]);
return (
<div
className={`${styles.item} ${isSelected ? styles.active : ""}`}
onClick={onSelectDialog}
role="button"
>
<div className={styles.avatar}>{client_name.charAt(0).toUpperCase()}</div>
<div className={styles.content}>
<div className={styles.header}>
<span className={styles.name}>{client_name}</span>
<span className={styles.time}>
{dayjs(last_message_).format("HH:mm")}
{message.is_read ? (
<BsCheck2All size="16px" />
) : (
<BsCheck2 size="16px" />
)}
</span>
</div>
<span className={styles.preview}>{last_message.text}</span>
{unread_count > 0 && (
<Badge>{unread_count}</Badge>
)}
</div>
</div>
);
}
export default ChatSidebarItem;
- useUrlState – синхронизация состояния с URL для сохранения выбранного диалога при перезагрузке;
- Badge – компонент для отображения счетчика непрочитанных сообщений. Подробнее ознакомиться с компонентом можно на демо-стенде: https://admiral.dev.family/components/badge.
4.3 ChatPanel – Панель сообщений
// src/crud/chat/components/ChatPanel.tsx
import React from "react";
import { Card } from '@devfamily/admiral';
import { useChat } from "../../contexts/ChatContext";
import MessageFeed from "../MessageFeed";
import MessageInput from "../MessageInput";
import styles from "./ChatPanel.module.scss";
function ChatPanel() {
const { selectedDialog } = useChat();
if (!selectedDialog) {
return (
<Card className={styles.emptyPanel}>
<div className={styles.emptyState}>
<h3>Выберите диалог</h3>
<p>Выберите диалог из списка для начала общения</p>
</div>
</Card>
);
}
return (
<div className={styles.panel}>
<MessageFeed />
<div className={styles.divider} />
<MessageInput />
</div>
);
}
export default ChatPanel;
4.4. MessageFeed – Лента сообщений
// src/crud/chat/components/MessageFeed.tsx
import React, { useRef, useEffect } from "react";
import { BsCheck2, BsCheck2All } from "react-icons/bs";
import { useChat } from "../../contexts/ChatContext";
import MessageItem from "../MessageItem";
import styles from "./MessageFeed.module.scss";
function MessageFeed() {
const { messages } = useChat();
const scrollRef = useRef(null);
useEffect(() => {
scrollRef.current?.scrollIntoView({ behavior: "auto" });
}, [messages]);
return (
<div ref={scrollRef} className={styles.feed}>
{messages.map((group) => (
<div key={group.date} className={styles.dateGroup}>
<div className={styles.dateDivider}>
<span>{group.date}</span>
</div>
{group.messages.map((msg) => (
<div className={styles.message}>
{msg.text && <p>{msg.text}</p>}
{msg.image && (
<img
src={msg.image}
alt=""
style={{ maxWidth: "200px", borderRadius: 4 }}
/>
)}
{msg.file && (
<a href={msg.file} target="_blank" rel="noopener noreferrer">
Скачать файл
</a>
)}
<div style={{ fontSize: "0.8rem", opacity: 0.6 }}>
{dayjs(msg.created_at).format("HH:mm")}
{msg.is_read ? <BsCheck2All /> : <BsCheck2 />}
</div>
</div>
))}
</div>
))}
</div>
);
}
export default MessageFeed;
4.5. MessageInput – Поле ввода сообщения
// src/crud/chat/components/MessageInput.tsx
import React from "react";
import {
ChangeEventHandler,
useCallback,
useEffect,
useRef,
useState,
} from "react";
import { FiPaperclip } from "react-icons/fi";
import { RxPaperPlane } from "react-icons/rx";
import { Form, Button, useUrlState, Textarea } from "@devfamily/admiral";
import { useChat } from "../../model/ChatContext";
import styles from "./MessageInput.module.scss";
function MessageInput() {
const { sendMessage } = useChat();
const [urlState] = useUrlState();
const { client_id } = urlState;
const [values, setValues] = useState({});
const textRef = useRef < HTMLTextAreaElement > null;
useEffect(() => {
setValues({});
setErrors(null);
}, [client_id]);
const onSubmit = useCallback(
async (e?: React.FormEvent<HTMLFormElement>) => {
e?.preventDefault();
const textIsEmpty = !values.text?.trim()?.length;
sendMessage(
{
...(values.image && { image: values.image }),
...(!textIsEmpty && { text: values.text }),
client_id,
},
() => {
setValues({ text: "" });
},
(err: any) => {
if (err.errors) {
setErrors(err.errors);
}
}
);
},
[values, sendMessage, client_id]
);
const onUploadFile: ChangeEventHandler<HTMLInputElement> = useCallback(
(e) => {
const file = Array.from(e.target.files || [])[0];
setValues((prev: any) => ({ ...prev, image: file }));
e.target.value = "";
},
[values]
);
const onChange = useCallback((e) => {
setValues((prev) => ({ ...prev, text: e.target.value }));
}, []);
const onKeyDown = useCallback((e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if ((e.code === "Enter" || e.code === "NumpadEnter") && !e.shiftKey) {
onSubmit();
e.preventDefault();
}
}, [onSubmit]);
return (
<form className={styles.form} onSubmit={onSubmit}>
<label className={styles.upload}>
<input
type="file"
onChange={onUploadFile}
className={styles.visuallyHidden}
/>
<FiPaperclip size="24px" />
</label>
<Textarea
value={values.text ?? ""}
onChange={onChange}
rows={1}
onKeyDown={onKeyDown}
placeholder="Написать сообщение..."
ref={textRef}
className={styles.textarea}
/>
<Button
view="secondary"
type="submit"
disabled={!values.image && !values.text?.trim().length}
className={styles.submitBtn}
>
<RxPaperPlane />
</Button>
</form>
);
}
export default MessageInput;
Стилизация
.chat {
border-radius: var(--radius-m);
border: 2px solid var(--color-bg-border);
background-color: var(--color-bg-default);
}
.message {
padding: var(--space-m);
border-radius: var(--radius-s);
background-color: var(--color-bg-default);
}
Добавление уведомлений прямо в ChatContext
import { useNotifications } from '@devfamily/admiral'
const ChatContext = () => {
const { showNotification } = useNotifications()
useEffect(() => {
if (!lastMessage) return
if (selectedDialog?.client_id !== lastMessage.client_id) {
showNotification({
title: 'Новое сообщение',
message: `${lastMessage.client_name}: ${lastMessage.text || 'Изображение'}`,
type: 'info',
duration: 5000
})
}
}, [lastMessage, selectedDialog, showNotification])
}
Заключение
- Простота интеграции – никаких сложных настроек роутинга или конфигураций
- Гибкость архитектуры – легко добавлять собственные контексты и компоненты
- Встроенные возможности – хуки для авторизации, темизации, навигации работают из коробки.
- Консистентность дизайна – все компоненты автоматически соответствуют общему стилю.