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.
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.
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 stateWith 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.
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().
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.
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
Then I moved to the UI layer.
// 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;
// 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;
// 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;
// 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;
// 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;
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); }
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]) }
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!


Lawmakers in the US House of Representatives and Senate met with cryptocurrency industry leaders in three separate roundtable events this week. Members of the US Congress met with key figures in the cryptocurrency industry to discuss issues and potential laws related to the establishment of a strategic Bitcoin reserve and a market structure.On Tuesday, a group of lawmakers that included Alaska Representative Nick Begich and Ohio Senator Bernie Moreno met with Strategy co-founder Michael Saylor and others in a roundtable event regarding the BITCOIN Act, a bill to establish a strategic Bitcoin (BTC) reserve. The discussion was hosted by the advocacy organization Digital Chamber and its affiliates, the Digital Power Network and Bitcoin Treasury Council.“Legislators and the executives at yesterday’s roundtable agree, there is a need [for] a Strategic Bitcoin Reserve law to ensure its longevity for America’s financial future,” Hailey Miller, director of government affairs and public policy at Digital Power Network, told Cointelegraph. “Most attendees are looking for next steps, which may mean including the SBR within the broader policy frameworks already advancing.“Read more
