EzyChat sử dụng EzyFox JavaScript Client để duy trì kết nối thời gian thực giữa trình duyệt và socket server. Quá trình khởi tạo client gồm bốn giai đoạn:
sequenceDiagram
    actor Browser as Trình duyệt
    participant API as Web API
    participant Socket as Socket server
    participant Chat as EzyChat app

    Browser->>API: Lấy socket access token
    API-->>Browser: accessToken
    Browser->>Socket: Mở kết nối WebSocket
    Socket-->>Browser: Handshake
    Browser->>Socket: Đăng nhập bằng access token
    Socket-->>Browser: Đăng nhập thành công
    Browser->>Chat: Truy cập app ezychat
    Chat-->>Browser: App sẵn sàng
    Browser->>Chat: Gửi command message
    Chat-->>Browser: Push message đã lưu
Client chỉ nên gửi tin nhắn sau khi đã đăng nhập và truy cập thành công app ezychat.

Cài đặt thư viện

Cài EzyFox ES6 Client bằng npm:
npm install ezyfox-es6-client
Sau đó import thư viện:
import Ezy from 'ezyfox-es6-client';
Các ví dụ trong bài sử dụng ES module. Nếu dự án nạp EzyFox Client bằng thẻ <script>, tên các class toàn cục có thể khác, chẳng hạn EzyClientConfig, EzyClients và EzyCommand.

Chuẩn bị cấu hình

EzyChat sử dụng các giá trị mặc định sau:
const ZONE_NAME = 'chat';
const APP_NAME = 'ezychat';

const Commands = {
  MESSAGE: 'message',
  TYPING: 'typing',
  CHAT_BOT: 'chatBot',
  CHAT_BOT_TYPING: 'botTyping',
};
Ứng dụng cần cung cấp:
  • URL của socket server.
  • Hàm lấy socket access token.
  • Tên trường chứa access token khi đăng nhập.
Ví dụ:
const socketOptions = {
  websocketUrl: 'wss://chat.example.com/ws',
  socketLoginField: 'accessToken',

  async getAccessToken() {
    const response = await fetch(
      '/ezylogin/api/v1/socket-access-token',
      {
        credentials: 'include',
      },
    );

    if (!response.ok) {
      throw new Error(
        `Can not get socket access token: `,
      );
    }

    const data = await response.json();
    return data.accessToken;
  },
};
Access token nên được lấy từ backend sau khi người dùng đã xác thực. Không nhúng token cố định vào mã JavaScript.
Nếu trang web dùng HTTPS, socket URL phải dùng wss://.

Khởi tạo EzyFox Client

Tạo cấu hình client với zone chat:
function createClientConfig() {
  const config = new Ezy.ClientConfig();

  config.zoneName = ZONE_NAME;
  config.reconnect.maxReconnectCount = 32768;

  return config;
}
Khởi tạo client:
const config = createClientConfig();
const clients = Ezy.Clients.getInstance();
const client = clients.newClient(config);
const { setup } = client;
Ezy.Clients quản lý các client đã được tạo. Nếu ứng dụng có nhiều kết nối socket, mỗi client cần được quản lý riêng để tránh đăng ký handler trùng lặp.

Theo dõi trạng thái kết nối

Một kết nối socket thường đi qua các trạng thái:
offline -> connecting -> online
Sự kiện CONNECTION_SUCCESS chỉ cho biết kết nối transport đã được mở. Client vẫn phải hoàn thành handshake, đăng nhập và app access trước khi được xem là online.
let connectionStatus = 'offline';
let chatApp = null;

function changeConnectionStatus(status) {
  connectionStatus = status;
  console.log('Socket status:', status);
}

const connectionSuccessHandler =
  new Ezy.ConnectionSuccessHandler();

connectionSuccessHandler.postHandle = () => {
  changeConnectionStatus('connecting');
};

const connectionFailureHandler =
  new Ezy.ConnectionFailureHandler();

connectionFailureHandler.postHandle = () => {
  chatApp = null;
  changeConnectionStatus('offline');
};

const disconnectionHandler =
  new Ezy.DisconnectionHandler();

disconnectionHandler.preHandle = () => {
  chatApp = null;
  changeConnectionStatus('offline');
};

setup.addEventHandler(
  Ezy.EventType.CONNECTION_SUCCESS,
  connectionSuccessHandler,
);

setup.addEventHandler(
  Ezy.EventType.CONNECTION_FAILURE,
  connectionFailureHandler,
);

setup.addEventHandler(
  Ezy.EventType.DISCONNECTION,
  disconnectionHandler,
);
Giao diện có thể dựa vào connectionStatus để bật hoặc tắt nút gửi tin nhắn.

Đăng nhập bằng access token

Sau khi handshake hoàn tất, client lấy access token và gửi lệnh đăng nhập:
const handshakeHandler = new Ezy.HandshakeHandler();

handshakeHandler.handleLogin = async () => {
  try {
    const accessToken =
      await socketOptions.getAccessToken();

    const loginData = {
      [socketOptions.socketLoginField]: accessToken,
    };

    const loginRequest = [
      config.zoneName,
      'useAccessToken',
      'useAccessToken',
      loginData,
    ];

    client.sendRequest(
      Ezy.Command.LOGIN,
      loginRequest,
    );
  } catch (error) {
    chatApp = null;
    changeConnectionStatus('offline');
    console.error('Socket login failed:', error);
  }
};

setup.addDataHandler(
  Ezy.Command.HANDSHAKE,
  handshakeHandler,
);
Hai giá trị username và password trong ví dụ chỉ đóng vai trò placeholder. Danh tính thật của người dùng được xác định từ access token.
Không gửi userId, username hoặc loại người dùng do client tự khai báo để thay thế cho token.

Truy cập EzyChat app

Sau khi đăng nhập thành công, server trả về thông tin danh tính socket. Client lưu lại thông tin này rồi yêu cầu truy cập app ezychat:
let currentUser = null;

const loginSuccessHandler =
  new Ezy.LoginSuccessHandler();

loginSuccessHandler.handleLoginSuccess = (data) => {
  currentUser = {
    ...client.me,
    userType: data.socketUserType,
    userUuid: data.socketUserUuid,
    userUuidWithType:
      `#`,
  };

  client.sendRequest(
    Ezy.Command.APP_ACCESS,
    [APP_NAME],
  );
};

setup.addDataHandler(
  Ezy.Command.LOGIN,
  loginSuccessHandler,
);
Giá trị userUuidWithType hữu ích khi client cần phân biệt sự kiện của chính mình với sự kiện từ thành viên khác.
Tiếp theo, lưu app sau khi truy cập thành công:
const appAccessHandler =
  new Ezy.AppAccessHandler();

appAccessHandler.postHandle = (app, data) => {
  chatApp = app;
  changeConnectionStatus('online');

  console.log('EzyChat app is ready', data);
};

setup.addDataHandler(
  Ezy.Command.APP_ACCESS,
  appAccessHandler,
);
Thời điểm postHandle được gọi mới là lúc client sẵn sàng gửi các command của EzyChat.

Đăng ký handler nhận tin nhắn

Handler của app nên được đăng ký trước khi gọi connect():
const chatSetup = setup.setupApp(APP_NAME);

chatSetup.addDataHandler(
  Commands.MESSAGE,
  (app, data) => {
    console.log('Received message:', data);
  },
);
Command message được sử dụng theo cả hai hướng:
  • Client gửi yêu cầu tạo tin nhắn.
  • Server phát tin nhắn đã lưu về các thành viên trong kênh.
Server cũng phát message về cho chính người gửi. Client có thể dùng phản hồi này làm dữ liệu chính thức để cập nhật giao diện.
Ví dụ:
function handleIncomingMessage(message) {
  if (message.channelId === activeChannelId) {
    appendMessageToConversation(message);
    scrollConversationToBottom();
    return;
  }

  increaseChannelUnreadCount(message.channelId);
  moveChannelToTop(message.channelId);
}

chatSetup.addDataHandler(
  Commands.MESSAGE,
  (app, message) => {
    handleIncomingMessage(message);
  },
);

Mở kết nối socket

Sau khi đã đăng ký toàn bộ handler, mở kết nối:
client.connect(socketOptions.websocketUrl);
Không nên gọi connect() nhiều lần trong khi client đang kết nối.
let connecting = false;

function connect() {
  if (connecting || connectionStatus === 'online') {
    return;
  }

  connecting = true;
  changeConnectionStatus('connecting');
  client.connect(socketOptions.websocketUrl);
}
Khi app access thành công hoặc kết nối thất bại, cập nhật lại cờ:
appAccessHandler.postHandle = (app, data) => {
  chatApp = app;
  connecting = false;
  changeConnectionStatus('online');
};

connectionFailureHandler.postHandle = () => {
  chatApp = null;
  connecting = false;
  changeConnectionStatus('offline');
};

disconnectionHandler.preHandle = () => {
  chatApp = null;
  connecting = false;
  changeConnectionStatus('offline');
};

Gửi tin nhắn văn bản

Payload tối thiểu gồm channelId và message:
function sendMessage(channelId, message) {
  const content = (message || '').trim();

  if (!chatApp) {
    return false;
  }

  if (!(channelId > 0) || !content) {
    return false;
  }

  chatApp.send(Commands.MESSAGE, {
    channelId,
    message: content,
    repliedMessageId: 0,
  });

  return true;
}
Cách gọi:
const sent = sendMessage(
  123,
  'Xin chào, tôi cần được hỗ trợ.',
);

if (sent) {
  clearMessageInput();
}
Một số phiên bản EzyFox JavaScript Client cung cấp sendRequest thay cho send:
chatApp.sendRequest(
  Commands.MESSAGE,
  payload,
);
Hãy dùng phương thức được cung cấp bởi phiên bản SDK đang tích hợp. Với ezyfox-es6-client, EzyChat hiện sử dụng app.send(...).

Cấu trúc payload tin nhắn

Một payload đầy đủ có thể chứa các trường:
TrườngKiểuÝ nghĩa
channelIdNumberID kênh nhận tin nhắn
messageStringNội dung tin nhắn
contentTypeStringTEXT hoặc JSON
repliedMessageIdNumberID tin nhắn đang được trả lời
threadIdNumberID thread
parentIdNumberID tin nhắn cha
titleStringTiêu đề tùy chọn
mediaIdNumberMedia chính
mediaIdsNumber[]Danh sách media đính kèm
linksString[]Danh sách đường dẫn
typeStringLoại tin nhắn nghiệp vụ
templateIdNumberID mẫu tin nhắn
Nếu không truyền contentType, server sử dụng TEXT.
Không cần gửi thông tin người gửi. Server lấy danh tính từ phiên socket đã đăng nhập.

Gửi tin nhắn trả lời

Để trả lời một tin nhắn, truyền repliedMessageId:
function replyMessage(
  channelId,
  repliedMessageId,
  message,
) {
  const content = (message || '').trim();

  if (
    !chatApp
    || !(channelId > 0)
    || !(repliedMessageId > 0)
    || !content
  ) {
    return false;
  }

  chatApp.send(Commands.MESSAGE, {
    channelId,
    message: content,
    repliedMessageId,
  });

  return true;
}
Sau khi gửi, giao diện nên xóa trạng thái reply để tin nhắn tiếp theo không tiếp tục tham chiếu tin nhắn cũ.

Gửi tin nhắn kèm media

Media phải được upload trước. Sau khi API upload trả về ID, gửi các ID đó trong mediaIds:
function sendMessageWithMedia({
  channelId,
  message,
  mediaIds,
  repliedMessageId = 0,
}) {
  if (!chatApp || !(channelId > 0)) {
    return false;
  }

  const normalizedMediaIds =
    Array.isArray(mediaIds) ? mediaIds : [];

  const payload = {
    channelId,
    message: (message || '').trim(),
    repliedMessageId,
  };

  if (normalizedMediaIds.length > 0) {
    payload.mediaIds = normalizedMediaIds;
  }

  chatApp.send(Commands.MESSAGE, payload);
  return true;
}
Ví dụ:
sendMessageWithMedia({
  channelId: 123,
  message: 'Tôi gửi kèm ảnh lỗi.',
  mediaIds: [901, 902],
});
Socket không upload nội dung file trong command message. Nó chỉ gửi ID của các media đã tồn tại trong hệ thống.

Gửi danh sách đường dẫn

Client có thể trích xuất URL trong nội dung và gửi kèm trường links:
const URL_PATTERN = /(https?://[^s]+)/g;

function extractLinks(message) {
  const matches = message.match(URL_PATTERN);

  return matches
    ? [...new Set(matches)]
    : [];
}
Tạo payload:
function createMessagePayload({
  channelId,
  message,
  repliedMessageId = 0,
  mediaIds = [],
}) {
  const content = (message || '').trim();
  const links = extractLinks(content);

  return {
    channelId,
    message: content,
    repliedMessageId,

    ...(mediaIds.length > 0 && {
      mediaIds,
    }),

    ...(links.length > 0 && {
      links,
    }),
  };
}

Gửi nội dung JSON

EzyChat hỗ trợ TEXT và JSON. Với JSON, trường message vẫn phải là chuỗi:
chatApp.send(Commands.MESSAGE, {
  channelId: 123,
  contentType: 'JSON',
  message: JSON.stringify({
    action: 'open-order',
    orderId: 456,
  }),
  repliedMessageId: 0,
});
Server kiểm tra chuỗi JSON trước khi lưu. Request sẽ bị từ chối nếu contentType không hợp lệ hoặc message không phải JSON hợp lệ.

Gửi trạng thái đang nhập

Command typing có payload nhẹ hơn:
function sendTyping(channelId, typing) {
  if (!chatApp || !(channelId > 0)) {
    return false;
  }

  chatApp.send(Commands.TYPING, {
    channelId,
    typing,
  });

  return true;
}
Khi người dùng bắt đầu nhập:
messageInput.addEventListener('input', () => {
  sendTyping(activeChannelId, true);
});
Trước khi gửi message, nên tắt trạng thái đang nhập:
function sendChatMessage(channelId, message) {
  sendTyping(channelId, false);
  return sendMessage(channelId, message);
}
Đăng ký handler nhận trạng thái:
chatSetup.addDataHandler(
  Commands.TYPING,
  (app, data) => {
    if (
      data.senderUuid
      === currentUser?.userUuidWithType
    ) {
      return;
    }

    showTypingStatus(
      data.channelId,
      data.typing,
    );
  },
);
Giao diện nên tự xóa trạng thái đang nhập sau một khoảng timeout. Điều này xử lý trường hợp client gửi typing: true rồi mất kết nối trước khi gửi typing: false.

Tạo lớp EzyChat socket client hoàn chỉnh

Có thể đóng gói toàn bộ logic trong một module:
import Ezy from 'ezyfox-es6-client';

const ZONE_NAME = 'chat';
const APP_NAME = 'ezychat';

const Commands = {
  MESSAGE: 'message',
  TYPING: 'typing',
  CHAT_BOT: 'chatBot',
  CHAT_BOT_TYPING: 'botTyping',
};

export function createEzyChatClient(options) {
  const config = new Ezy.ClientConfig();

  config.zoneName = ZONE_NAME;
  config.reconnect.maxReconnectCount =
    options.maxReconnectCount ?? 32768;

  const client = Ezy.Clients
    .getInstance()
    .newClient(config);

  const { setup } = client;
  const listeners = new Map();

  let app = null;
  let currentUser = null;
  let connecting = false;
  let status = 'offline';

  function emit(eventName, data) {
    const eventListeners =
      listeners.get(eventName) || [];

    eventListeners.forEach((listener) => {
      listener(data);
    });
  }

  function setStatus(nextStatus) {
    status = nextStatus;
    emit('statusChanged', nextStatus);
  }

  function listen(eventName, listener) {
    const eventListeners =
      listeners.get(eventName) || [];

    eventListeners.push(listener);
    listeners.set(eventName, eventListeners);

    return () => {
      const currentListeners =
        listeners.get(eventName) || [];

      listeners.set(
        eventName,
        currentListeners.filter(
          (item) => item !== listener,
        ),
      );
    };
  }

  const connectionSuccessHandler =
    new Ezy.ConnectionSuccessHandler();

  connectionSuccessHandler.postHandle = () => {
    setStatus('connecting');
  };

  const connectionFailureHandler =
    new Ezy.ConnectionFailureHandler();

  connectionFailureHandler.postHandle = (event) => {
    app = null;
    connecting = false;
    setStatus('offline');
    emit('connectionFailed', event);
  };

  const disconnectionHandler =
    new Ezy.DisconnectionHandler();

  disconnectionHandler.preHandle = (event) => {
    app = null;
    connecting = false;
    setStatus('offline');
    emit('disconnected', event);
  };

  const handshakeHandler =
    new Ezy.HandshakeHandler();

  handshakeHandler.handleLogin = async () => {
    try {
      const accessToken =
        await options.getAccessToken();

      client.sendRequest(
        Ezy.Command.LOGIN,
        [
          config.zoneName,
          'useAccessToken',
          'useAccessToken',
          {
            [options.socketLoginField
              || 'accessToken']: accessToken,
          },
        ],
      );
    } catch (error) {
      app = null;
      connecting = false;
      setStatus('offline');
      emit('loginFailed', error);
    }
  };

  const loginSuccessHandler =
    new Ezy.LoginSuccessHandler();

  loginSuccessHandler.handleLoginSuccess = (data) => {
    currentUser = {
      ...client.me,
      userType: data.socketUserType,
      userUuid: data.socketUserUuid,
      userUuidWithType:
        ``
        + `#`,
    };

    client.sendRequest(
      Ezy.Command.APP_ACCESS,
      [APP_NAME],
    );
  };

  const appAccessHandler =
    new Ezy.AppAccessHandler();

  appAccessHandler.postHandle = (
    accessedApp,
    data,
  ) => {
    app = accessedApp;
    connecting = false;
    setStatus('online');
    emit('ready', data);
  };

  setup.addEventHandler(
    Ezy.EventType.CONNECTION_SUCCESS,
    connectionSuccessHandler,
  );

  setup.addEventHandler(
    Ezy.EventType.CONNECTION_FAILURE,
    connectionFailureHandler,
  );

  setup.addEventHandler(
    Ezy.EventType.DISCONNECTION,
    disconnectionHandler,
  );

  setup.addDataHandler(
    Ezy.Command.HANDSHAKE,
    handshakeHandler,
  );

  setup.addDataHandler(
    Ezy.Command.LOGIN,
    loginSuccessHandler,
  );

  setup.addDataHandler(
    Ezy.Command.APP_ACCESS,
    appAccessHandler,
  );

  const chatSetup = setup.setupApp(APP_NAME);

  chatSetup.addDataHandler(
    Commands.MESSAGE,
    (socketApp, data) => {
      emit('message', data);
    },
  );

  chatSetup.addDataHandler(
    Commands.TYPING,
    (socketApp, data) => {
      emit('typing', data);
    },
  );

  chatSetup.addDataHandler(
    Commands.CHAT_BOT,
    (socketApp, data) => {
      emit('chatBot', data);
    },
  );

  chatSetup.addDataHandler(
    Commands.CHAT_BOT_TYPING,
    (socketApp, data) => {
      emit('botTyping', data);
    },
  );

  function connect() {
    if (connecting || status === 'online') {
      return false;
    }

    connecting = true;
    setStatus('connecting');
    client.connect(options.websocketUrl);
    return true;
  }

  function send(command, data) {
    if (!app || status !== 'online') {
      return false;
    }

    app.send(command, data);
    return true;
  }

  function sendMessage({
    channelId,
    message,
    repliedMessageId = 0,
    mediaIds = [],
    links,
    contentType,
  }) {
    if (!(channelId > 0)) {
      return false;
    }

    const content = (message || '').trim();
    const messageLinks =
      links || extractLinks(content);

    if (!content && mediaIds.length === 0) {
      return false;
    }

    sendTyping(channelId, false);

    return send(Commands.MESSAGE, {
      channelId,
      message: content,
      repliedMessageId,

      ...(contentType && {
        contentType,
      }),

      ...(mediaIds.length > 0 && {
        mediaIds,
      }),

      ...(messageLinks.length > 0 && {
        links: messageLinks,
      }),
    });
  }

  function sendTyping(channelId, typing) {
    if (!(channelId > 0)) {
      return false;
    }

    return send(Commands.TYPING, {
      channelId,
      typing,
    });
  }

  return {
    Commands,
    connect,
    listen,
    send,
    sendMessage,
    sendTyping,

    getStatus() {
      return status;
    },

    getCurrentUser() {
      return currentUser;
    },

    isReady() {
      return Boolean(
        app && status === 'online',
      );
    },
  };
}

const URL_PATTERN = /(https?://[^s]+)/g;

function extractLinks(message) {
  const matches = message.match(URL_PATTERN);
  return matches ? [...new Set(matches)] : [];
}

Sử dụng client

Khởi tạo:
const chatClient = createEzyChatClient({
  websocketUrl: 'wss://chat.example.com/ws',

  async getAccessToken() {
    const response = await fetch(
      '/ezylogin/api/v1/socket-access-token',
      {
        credentials: 'include',
      },
    );

    if (!response.ok) {
      throw new Error('Can not get access token');
    }

    const data = await response.json();
    return data.accessToken;
  },
});
Đăng ký các listener trước khi kết nối:
chatClient.listen('statusChanged', (status) => {
  updateConnectionIndicator(status);
});

chatClient.listen('ready', () => {
  enableMessageInput();
});

chatClient.listen('message', (message) => {
  handleIncomingMessage(message);
});

chatClient.listen('typing', (data) => {
  const currentUser =
    chatClient.getCurrentUser();

  if (
    data.senderUuid
    === currentUser?.userUuidWithType
  ) {
    return;
  }

  showTypingStatus(
    data.channelId,
    data.typing,
  );
});

chatClient.listen('disconnected', () => {
  disableMessageInput();
});
Mở kết nối:
chatClient.connect();
Gửi tin nhắn:
const sent = chatClient.sendMessage({
  channelId: activeChannelId,
  message: messageInput.value,
  repliedMessageId:
    replyingMessage?.id || 0,
  mediaIds: uploadedMedia.map(
    (media) => media.id,
  ),
});

if (sent) {
  messageInput.value = '';
  replyingMessage = null;
  uploadedMedia = [];
}
Chỉ xóa nội dung nhập khi sendMessage() trả về true. Nếu socket chưa sẵn sàng, người dùng vẫn giữ được nội dung để gửi lại.

Cập nhật giao diện khi nhận message

Client không nên tự coi yêu cầu vừa gửi là message cuối cùng. Server còn thực hiện các bước:
  • Kiểm tra quyền gửi vào kênh.
  • Kiểm tra trạng thái khóa.
  • Lưu tin nhắn.
  • Gán ID.
  • Chuẩn hóa thông tin người gửi.
  • Gắn thông tin media và tin nhắn được trả lời.
  • Cập nhật trạng thái và số lượng chưa đọc.
  • Phát kết quả tới các thành viên.
Vì vậy, cách đơn giản nhất là cập nhật danh sách tin nhắn khi nhận command message từ server:
chatClient.listen('message', (message) => {
  if (message.channelId === activeChannelId) {
    messages.push(message);
    renderMessages(messages);
    return;
  }

  unreadCountByChannelId[message.channelId] =
    (unreadCountByChannelId[message.channelId] || 0)
    + 1;

  renderChannelList();
});
Nếu cần optimistic UI, hãy tạo ID tạm phía client và có cơ chế đối chiếu với message do server trả về. Nếu chỉ thêm cả bản tạm lẫn bản từ socket, giao diện sẽ hiển thị trùng tin nhắn.

Đồng bộ sau khi reconnect

Socket chỉ chuyển các sự kiện thời gian thực. Nó không thay thế API lấy lịch sử.
Sau khi reconnect và app access thành công, client nên tải lại các message gần nhất:
chatClient.listen('ready', async () => {
  const response = await fetch(
    `/api/channels//messages`,
    {
      credentials: 'include',
    },
  );

  if (!response.ok) {
    return;
  }

  const pagination = await response.json();
  replaceMessages(pagination.items);
});
Việc này giúp khôi phục những message được gửi trong lúc client mất kết nối.

Các lỗi thường gặp

WebSocket đã kết nối nhưng không gửi được message
Kết nối transport đã thành công nhưng client chưa hoàn tất đăng nhập hoặc app access. Chỉ gửi khi trạng thái là online.
Không nhận được tin nhắn vừa gửi
Kiểm tra handler cho command message đã được đăng ký trên app ezychat trước khi kết nối.
Server báo không tìm thấy kênh
Người dùng hiện tại không phải thành viên của channelId, hoặc client đang dùng ID kênh cũ.
Server báo kênh bị khóa
Thành viên không được phép gửi thêm tin nhắn trong kênh đó.
Đăng nhập thất bại
Kiểm tra access token còn hạn, field đăng nhập có đúng là accessToken, zone có phải chat và token có được cấp cho đúng socket server hay không.
Client kết nối lặp lại
Không gọi connect() khi client đang ở trạng thái connecting hoặc online.
Message xuất hiện hai lần
Giao diện vừa thêm message ngay khi bấm gửi, vừa thêm lần nữa khi server push kết quả. Hãy chọn dữ liệu server làm nguồn chính hoặc triển khai đối chiếu optimistic message.
Mất message trong thời gian offline
Sau khi reconnect, tải lại lịch sử gần nhất qua API. Không giả định socket server sẽ phát lại toàn bộ sự kiện cũ.

Checklist tích hợp

  • Đã cài ezyfox-es6-client.
  • Zone name là chat.
  • App name là ezychat.
  • WebSocket URL dùng wss:// trên môi trường HTTPS.
  • Access token được lấy từ backend.
  • Handler được đăng ký trước khi gọi connect().
  • Client chỉ gửi message sau khi app access thành công.
  • Payload có channelId hợp lệ.
  • Client không tự gửi senderId.
  • Media được upload trước khi gửi mediaIds.
  • Command message được dùng để nhận dữ liệu chính thức từ server.
  • Trạng thái typing được tắt khi gửi message.
  • Client tải lại lịch sử sau reconnect.
  • Nút gửi giữ nguyên nội dung nếu socket chưa sẵn sàng.