By using this site, you agree to the Privacy Policy and Terms of Use.
Accept
World of SoftwareWorld of SoftwareWorld of Software
  • News
  • Software
  • Mobile
  • Computing
  • Gaming
  • Videos
  • More
    • Gadget
    • Web Stories
    • Trending
    • Press Release
Search
  • Privacy
  • Terms
  • Advertise
  • Contact
Copyright © All Rights Reserved. World of Software.
Reading: I Built My Own Chat Instead of Relying on Jivo or LiveChat: Here’s How | HackerNoon
Share
Sign In
Notification Show More
Font ResizerAa
World of SoftwareWorld of Software
Font ResizerAa
  • Software
  • Mobile
  • Computing
  • Gadget
  • Gaming
  • Videos
Search
  • News
  • Software
  • Mobile
  • Computing
  • Gaming
  • Videos
  • More
    • Gadget
    • Web Stories
    • Trending
    • Press Release
Have an existing account? Sign In
Follow US
  • Privacy
  • Terms
  • Advertise
  • Contact
Copyright © All Rights Reserved. World of Software.
World of Software > Computing > I Built My Own Chat Instead of Relying on Jivo or LiveChat: Here’s How | HackerNoon
Computing

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

News Room
Last updated: 2025/08/27 at 5:50 PM
News Room Published 27 August 2025
Share
SHARE

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!

Sign Up For Daily Newsletter

Be keep up! Get the latest breaking news delivered straight to your inbox.
By signing up, you agree to our Terms of Use and acknowledge the data practices in our Privacy Policy. You may unsubscribe at any time.
Share This Article
Facebook Twitter Email Print
Share
What do you think?
Love0
Sad0
Happy0
Sleepy0
Angry0
Dead0
Wink0
Previous Article Google’s new Android 16 statue takes a spin on a Material 3 Expressive merry-go-round
Next Article Anthropic Stops Hacker From Using Claude AI to Breach Companies
Leave a comment

Leave a Reply Cancel reply

Your email address will not be published. Required fields are marked *

Stay Connected

248.1k Like
69.1k Follow
134k Pin
54.3k Follow

Latest News

Bridging Backend Engineering with AI: The Rise of the Intelligent Platform Engineer | HackerNoon
Computing
PlayStation Plus Subscribers Are Eating With These Free September Games
News
IDC nearly doubles worldwide smartphone forecast for 2025 – 9to5Mac
News
Samsung is Unpacking again in early September
News

You Might also Like

Computing

Bridging Backend Engineering with AI: The Rise of the Intelligent Platform Engineer | HackerNoon

0 Min Read
Computing

Revolutionize Incident Management with Splunk and PagerDuty Automation | HackerNoon

0 Min Read
Computing

5 Tokens to Invest In as the Fed Ends Program That Increased Bank Scrutiny of Crypto | HackerNoon

0 Min Read
Computing

$MBG Token Supply Reduced By 4.86M In First Buyback And Burn By MultiBank Group | HackerNoon

4 Min Read
//

World of Software is your one-stop website for the latest tech news and updates, follow us now to get the news that matters to you.

Quick Link

  • Privacy Policy
  • Terms of use
  • Advertise
  • Contact

Topics

  • Computing
  • Software
  • Press Release
  • Trending

Sign Up for Our Newsletter

Subscribe to our newsletter to get our newest articles instantly!

World of SoftwareWorld of Software
Follow US
Copyright © All Rights Reserved. World of Software.
Welcome Back!

Sign in to your account

Lost your password?