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

Google's AP2 protocol has been released. Does encrypted AI still have a chance?

Google's AP2 protocol has been released. Does encrypted AI still have a chance?

Following the MCP and A2A protocols, the AI Agent market has seen another blockbuster arrival: the Agent Payments Protocol (AP2), developed by Google. This will clearly further enhance AI Agents' autonomous multi-tasking capabilities, but the unfortunate reality is that it has little to do with web3AI. Let's take a closer look: What problem does AP2 solve? Simply put, the MCP protocol is like a universal hook, enabling AI agents to connect to various external tools and data sources; A2A is a team collaboration communication protocol that allows multiple AI agents to cooperate with each other to complete complex tasks; AP2 completes the last piece of the puzzle - payment capability. In other words, MCP opens up connectivity, A2A promotes collaboration efficiency, and AP2 achieves value exchange. The arrival of AP2 truly injects "soul" into the autonomous collaboration and task execution of Multi-Agents. Imagine AI Agents connecting Qunar, Meituan, and Didi to complete the booking of flights, hotels, and car rentals, but then getting stuck at the point of "self-payment." What's the point of all that multitasking? So, remember this: AP2 is an extension of MCP+A2A, solving the last mile problem of AI Agent automated execution. What are the technical highlights of AP2? The core innovation of AP2 is the Mandates mechanism, which is divided into real-time authorization mode and delegated authorization mode. Real-time authorization is easy to understand. The AI Agent finds the product and shows it to you. The operation can only be performed after the user signs. Delegated authorization requires the user to set rules in advance, such as only buying the iPhone 17 when the price drops to 5,000. The AI Agent monitors the trigger conditions and executes automatically. The implementation logic is cryptographically signed using Verifiable Credentials (VCs). Users can set complex commission conditions, including price ranges, time limits, and payment method priorities, forming a tamper-proof digital contract. Once signed, the AI Agent executes according to the conditions, with VCs ensuring auditability and security at every step. Of particular note is the "A2A x402" extension, a technical component developed by Google specifically for crypto payments, developed in collaboration with Coinbase and the Ethereum Foundation. This extension enables AI Agents to seamlessly process stablecoins, ETH, and other blockchain assets, supporting native payment scenarios within the Web3 ecosystem. What kind of imagination space can AP2 bring? After analyzing the technical principles, do you think that's it? Yes, in fact, the AP2 is boring when it is disassembled alone. Its real charm lies in connecting and opening up the "MCP+A2A+AP2" technology stack, completely opening up the complete link of AI Agent's autonomous analysis+execution+payment. From now on, AI Agents can open up many application scenarios. For example, AI Agents for stock investment and financial management can help us monitor the market 24/7 and conduct independent transactions. Enterprise procurement AI Agents can automatically replenish and renew without human intervention. AP2's complementary payment capabilities will further expand the penetration of the Agent-to-Agent economy into more scenarios. Google obviously understands that after the technical framework is established, the ecological implementation must be relied upon, so it has brought in more than 60 partners to develop it, almost covering the entire payment and business ecosystem. Interestingly, it also involves major Crypto players such as Ethereum, Coinbase, MetaMask, and Sui. Combined with the current trend of currency and stock integration, the imagination space has been doubled. Is web3 AI really dead? Not entirely. Google's AP2 looks complete, but it only achieves technical compatibility with Crypto payments. It can only be regarded as an extension of the traditional authorization framework and belongs to the category of automated execution. There is a "paradigm" difference between it and the autonomous asset management pursued by pure Crypto native solutions. The Crypto-native solutions under exploration are taking the "decentralized custody + on-chain verification" route, including AI Agent autonomous asset management, AI Agent autonomous transactions (DeFAI), AI Agent digital identity and on-chain reputation system (ERC-8004...), AI Agent on-chain governance DAO framework, AI Agent NPC and digital avatars, and many other interesting and fun directions. Ultimately, once users get used to AI Agent payments in traditional fields, their acceptance of AI Agents autonomously owning digital assets will also increase. And for those scenarios that AP2 cannot reach, such as anonymous transactions, censorship-resistant payments, and decentralized asset management, there will always be a time for crypto-native solutions to show their strength? The two are more likely to be complementary rather than competitive, but to be honest, the key technological advancements behind AI Agents currently all come from web2AI, and web3AI still needs to keep up the good work!
Share
PANews2025/09/18 07:00