DEV Community

Cover image for How I Built My Own Chat Instead of Using Jivo or LiveChat
Dmitriy Kasperovich
Dmitriy Kasperovich

Posted on

How I Built My Own Chat Instead of Using Jivo or LiveChat

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
Enter fullscreen mode Exit fullscreen mode

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>
  )
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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;
};
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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])
}
Enter fullscreen mode Exit fullscreen mode

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!

Top comments (0)