So, I recently had a project where I needed a chat feature. My first thought was whether to just integrate an existing tool like Jivo or LiveChat, but I didn’t want to depend on third-party products for something that could be built directly into my admin panel.
In this post, I’ll go through how I built it: the architecture, the contexts for sockets and state, and the UI components that tied it all together.
Why Admiral?
Admiral is designed to be extensible. With file-based routing, hooks, and flexible components, it doesn’t lock you in—it gives you space to implement custom features. That’s exactly what I needed for chat: not just CRUD, but real-time messaging that still fit seamlessly into the panel.
Chat Architecture
Here’s how I structured things:
Core components
ChatPage
– the main chat pageChatSidebar
– conversation list with previewsChatPanel
– renders the selected chatMessageFeed
– the thread of messagesMessageInput
– the input with file upload
Context providers
SocketContext
– manages WebSocket connectionsChatContext
– manages dialogs and message state
Main Chat Page
With Admiral’s routing, setting up a new page was straightforward.
// pages/chat/index.tsx
import ChatPage from '@/src/crud/chat'
export default ChatPage
That was enough to make the page available at /chat
.
The main implementation went into src/crud/chat/index.tsx
:
// 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="Corporate chat" />
<div className={styles.chat}>
<ChatSidebar />
<ChatPanel />
</div>
</Card>
</ChatProvider>
</SocketProvider>
)
}
Here, I wrapped the page in SocketProvider
and ChatProvider
, and used Admiral’s hooks for permissions and redirects.
Managing WebSocket Connections With SocketContext
For real-time chat, I chose Centrifuge. I wanted all connection logic in one place, so I created 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, // Initializing the WebSocket connection with a token
})
centrifugeRef.current = centrifuge
centrifugeRef.current.connect()
// Subscribing to the chat channel
const sub = centrifugeRef.current.newSubscription(`admin_chat`)
sub.on('publication', function (ctx: any) {
setLastMessage(ctx.data);
}).subscribe()
// Cleaning up on component unmount
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
}
This context handled connection setup, subscription, and cleanup. Other parts of the app just used useSocket()
.
Managing Chat State With ChatContext
Next, I needed to fetch dialogs, load messages, send new ones, and react to WebSocket updates. For that, I created 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]
);
// Within this context, you can extend the logic to:
// – Mark messages as read (api.read())
// – Group messages by date, and more.
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;
};
This kept everything — fetching, storing, updating — in one place.
API Client Example
I added a small API client for requests:
// 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
UI Components: Sidebar + Panel + Input
Then I moved to the UI layer.
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>No active активных dialogs</span>
</div>
);
}
return <div className={styles.list}>
{dialogs.map((item) => (
<ChatSidebarItem key={item.id} data={item} />
))}
</div>
}
export default ChatSidebar;
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;
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>Choose the dialog</h3>
<p>Choose the dialog from the list to start conversation</p>
</div>
</Card>
);
}
return (
<div className={styles.panel}>
<MessageFeed />
<div className={styles.divider} />
<MessageInput />
</div>
);
}
export default ChatPanel;
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;
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;
Styling
I styled it using Admiral’s CSS variables to keep everything consistent:
.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);
}
Adding Notifications
I also added notifications for new messages when the user wasn’t viewing that chat:
import { useNotifications } from '@devfamily/admiral'
const ChatContext = () => {
const { showNotification } = useNotifications()
useEffect(() => {
if (!lastMessage) return
if (selectedDialog?.client_id !== lastMessage.client_id) {
showNotification({
title: 'New message',
message: `${lastMessage.client_name}: ${lastMessage.text || 'Image'}`,
type: 'info',
duration: 5000
})
}
}, [lastMessage, selectedDialog, showNotification])
}
Conclusion
And just like that, instead of using third-party tools, I built it directly into my Admiral-based admin panel. Admiral’s routing, contexts, hooks, and design system made it possible to build a real-time chat that felt native to the panel.
The result was a fully custom chat: real-time messaging, dialogs, file uploads, and notifications—all integrated and under my control.
Check it out, and let me know what you think!