The main implementation went into `src/crud/chat/index.tsx. The architecture, the contexts for sockets and state, and the UI components that tied it all together. I wanted all connection logic in one place, so I created `SocketContext': `ChatContext': 'SocketContext' with React.The main implementation went into `src/crud/chat/index.tsx. The architecture, the contexts for sockets and state, and the UI components that tied it all together. I wanted all connection logic in one place, so I created `SocketContext': `ChatContext': 'SocketContext' with React.

I Built My Own Chat Instead of Relying on Jivo or LiveChat: Here's How

2025/08/27 21:00

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 page
  • ChatSidebar – conversation list with previews
  • ChatPanel – renders the selected chat
  • MessageFeed – the thread of messages
  • MessageInput – the input with file upload

\ Context providers

  • SocketContext – manages WebSocket connections
  • ChatContext – 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!

Disclaimer: The articles reposted on this site are sourced from public platforms and are provided for informational purposes only. They do not necessarily reflect the views of MEXC. All rights remain with the original authors. If you believe any content infringes on third-party rights, please contact service@support.mexc.com for removal. MEXC makes no guarantees regarding the accuracy, completeness, or timeliness of the content and is not responsible for any actions taken based on the information provided. The content does not constitute financial, legal, or other professional advice, nor should it be considered a recommendation or endorsement by MEXC.
Share Insights

You May Also Like

Franklin Templeton CEO Dismisses 50bps Rate Cut Ahead FOMC

Franklin Templeton CEO Dismisses 50bps Rate Cut Ahead FOMC

The post Franklin Templeton CEO Dismisses 50bps Rate Cut Ahead FOMC appeared on BitcoinEthereumNews.com. Franklin Templeton CEO Jenny Johnson has weighed in on whether the Federal Reserve should make a 25 basis points (bps) Fed rate cut or 50 bps cut. This comes ahead of the Fed decision today at today’s FOMC meeting, with the market pricing in a 25 bps cut. Bitcoin and the broader crypto market are currently trading flat ahead of the rate cut decision. Franklin Templeton CEO Weighs In On Potential FOMC Decision In a CNBC interview, Jenny Johnson said that she expects the Fed to make a 25 bps cut today instead of a 50 bps cut. She acknowledged the jobs data, which suggested that the labor market is weakening. However, she noted that this data is backward-looking, indicating that it doesn’t show the current state of the economy. She alluded to the wage growth, which she remarked is an indication of a robust labor market. She added that retail sales are up and that consumers are still spending, despite inflation being sticky at 3%, which makes a case for why the FOMC should opt against a 50-basis-point Fed rate cut. In line with this, the Franklin Templeton CEO said that she would go with a 25 bps rate cut if she were Jerome Powell. She remarked that the Fed still has the October and December FOMC meetings to make further cuts if the incoming data warrants it. Johnson also asserted that the data show a robust economy. However, she noted that there can’t be an argument for no Fed rate cut since Powell already signaled at Jackson Hole that they were likely to lower interest rates at this meeting due to concerns over a weakening labor market. Notably, her comment comes as experts argue for both sides on why the Fed should make a 25 bps cut or…
Share
2025/09/18 00:36
Ethereum’s ERC-8004 Brings AI-Driven Economic Potential

Ethereum’s ERC-8004 Brings AI-Driven Economic Potential

The post Ethereum’s ERC-8004 Brings AI-Driven Economic Potential appeared on BitcoinEthereumNews.com. Key Points: ERC-8004 launch by Cobo enables AI as economic entities in crypto. No immediate market impact noted yet. Potential for significant future Ethereum ecosystem evolution. Cobo’s co-founder Fish the Godfish introduced a groundbreaking crypto stack—x402, AP2, and ERC-8004—on September 17th, enabling AI agents to transact as economic entities officially. This technical advancement fosters new machine involvement in economic activities within Ethereum, anticipated to alter future DeFi landscapes, despite no current financial or market impact observed. ERC-8004 and AI: Transforming Ethereum Transactions Cobo’s ERC-8004 aims to transform the cryptocurrency landscape by allowing AI agents to engage in economic activities, introducing a stack that interlinks x402 and AP2 for seamless transactions. Fish the Godfish, the primary architect of this initiative, has highlighted the potential for AI to evolve into true economic agents, changing how transactions are approached in blockchain ecosystems. The introduction of this stack is a technological milestone, though no immediate financial impact has surfaced. The stack positions Ethereum as a hub for machine-led commerce, foreshadowing future changes in decentralized finance and smart contract applications. When AI learns to spend: From x402 to AP2, and then to ERC-8004, explore how to make the Agent a true economic entity. — Fish the Godfish, Co-founder and CEO of Cobo Reactions to the announcement have been cautiously optimistic, with many in the community anticipating advancements, although industry influencers have yet to comment. This caution suggests that while the technical potential is acknowledged, its market and practical impacts remain speculative. Ethereum’s Evolution: AI Agents and Market Dynamics Did you know? ERC-8004, hailed as a significant advancement, has historical parallels with early smart contract technologies that first enabled programmable transactions on blockchains. Ethereum (ETH) is valued at $3,957.24 with a market cap of 477,631,941,155. Its 24-hour trading volume is $15.36 billion, showing a -55.14% change,…
Share
2025/10/26 07:35
XRP (XRP) Faces Potential Downturn as Death Cross Pattern Re-emerges

XRP (XRP) Faces Potential Downturn as Death Cross Pattern Re-emerges

The post XRP (XRP) Faces Potential Downturn as Death Cross Pattern Re-emerges appeared on BitcoinEthereumNews.com. Ted Hisokawa Oct 24, 2025 16:07 XRP is on the brink of forming a ‘death cross’ pattern, reminiscent of its 65% crash in 2021. Experts warn of potential risks including falling burn rate and insider selling. The price of XRP, the cryptocurrency developed by Ripple, is currently navigating a challenging phase, marked by a significant decline from its peak earlier this year. According to CoinMarketCap, XRP has dropped by 34% from its highest point, situating it firmly within a bearish market. Death Cross Pattern and Historical Context A looming ‘death cross’ pattern on the daily chart is raising alarms among analysts. This technical chart pattern, which occurs when a short-term moving average crosses below a long-term moving average, has historically signaled a potential downturn. The last instance of this pattern for XRP was in 2021, leading to a dramatic 65% price drop. Current Market Conditions As of October 23, XRP was trading at $2.4137, a price level that reflects recent volatility and market consolidation. This price action is consistent with broader trends observed across the altcoin market, where significant price swings have been common since early October. Despite these challenges, XRP remains a key player in the cryptocurrency space, backed by robust fundamentals. Additional Risks for XRP Beyond the technical patterns, XRP faces other risks that could impact its price. Notably, the burn rate for the token is declining, which could affect its perceived scarcity and value. Furthermore, insider selling has been flagged as a potential concern, possibly contributing to downward pressure on the price. Market Developments and Future Outlook In contrast to the current bearish sentiment, Ripple’s ecosystem continues to expand. The recent launch of the REX-Oprey XRP ETF has been a significant milestone, quickly surpassing $100 million in assets. This…
Share
2025/10/26 07:24