Web Totals

Học từ Facebook #96: Gom tin nhắn thành cụm và avatar đã xem

Messenger gom tin liền nhau của cùng một người thành cụm, chèn vạch thời gian khi cách quá 1 giờ và đặt avatar "đã xem" ở tin mới nhất mỗi người đọc.

hunghg255hunghg25525 min read
#facebook#messenger#chat#ui#algorithm
Học từ Facebook #96: Gom tin nhắn thành cụm và avatar đã xem

Mở một nhóm chat bất kỳ trên Messenger. An gửi ba tin liền nhau: bạn chỉ thấy một tên "An" ở trên và một avatar ở tin cuối, ba bong bóng dính vào nhau với góc bo nhỏ ở phía người gửi. Hai tiếng sau Bình nhắn lại: một dòng "23:55" mờ hiện ra ở giữa. Dưới tin cuối cùng có mấy avatar bé xíu: ai đã đọc tới đâu.

Nếu mỗi tin là một bong bóng có đủ tên và avatar, khung chat dài gấp rưỡi và rất khó đọc. Nhưng "gom" theo luật nào? Tin trả lời có gom không? Tin kèm ảnh? Một đoạn văn 300 chữ? Bài này đọc bộ phân tích của Messenger web để lấy đúng các luật đó, rồi viết lại thành vài hàm thuần.

Tip

Tóm tắt

  • Một lượt reduce theo thời gian: tin mới mở cụm mới khi đổi người gửi, cách tin trước quá 5 phút, là tin chuyển tiếp, hoặc một trong hai tin là tin "đứng riêng" (trả lời, có tệp đính kèm, dài hơn 280 ký tự, tin hệ thống).
  • Vạch "chưa đọc" của người xem cũng là một ranh giới cụm. Cách tin trước hơn 1 giờ thì chèn vạch thời gian.
  • Avatar "đã xem" của mỗi người nằm ở tin mới nhất họ đã thấy: tin họ tự gửi, hoặc tin cũ hơn mốc đã đọc (read watermark) của họ.
  • Bản viết lại tách luật ra hàm startsNewGroup, tìm avatar đã xem bằng một lượt đi ngược O(n) mỗi người, và thêm tuỳ chọn vạch khi sang ngày mới.

#Facebook làm thế nào?

Module MWLSMessageGroupsParser.bs nằm trong bundle 99 ChatComposer/all.js. Đuôi .bs nghĩa là code viết bằng ReasonML rồi dịch qua BuckleScript, nên số nguyên 64 bit đi qua bs_caml_int64 và kết quả là các biến thể {TAG: n, _0: …}. Trường dữ liệu bị rút gọn thành chữ cái: message.b là timestamp, message.h là người gửi, message.g là nội dung chữ, message.e là id tin, message.j là cờ tin hệ thống.

#Luật tách cụm

Giải mã hàm isInDifferentGroup(cur, prev, unreadMark) (đã đổi tên biến):

function isInDifferentGroup(cur, prev, unreadMark) {
  if (prev === undefined) return true;
  const tooFar   = ts(cur) - ts(prev) > MercuryThreadlistConstants.GROUPING_THRESHOLD; // 300000
  const fwd      = MWLSParserUtils.isForwarded(cur);
  const sameUser = int64.compare(cur.message.h, prev.message.h) === 0;
  const unread   = unreadMark !== undefined && ts(cur) > unreadMark && ts(prev) <= unreadMark;
  return !sameUser || tooFar || fwd || shouldBeStandalone(cur) || shouldBeStandalone(prev) || unread;
}

function shouldBeStandalone(m) {
  return isReplyToSomething(m) || m.has_attachment || isLongText(m) || m.message.j; // j: tin hệ thống
}
const isLongText = (m) => (m.message.g ?? '').length > MercuryThreadlistConstants.MAX_CHARS_BEFORE_BREAK; // 280
js

Hai hằng số nằm ở MercuryThreadlistConstants: GROUPING_THRESHOLD: 300000 (5 phút) và MAX_CHARS_BEFORE_BREAK: 280. Con số 280 trùng giới hạn tweet; có lẽ đội Messenger chọn nó như một mốc "đoạn văn dài" quen thuộc.

Vì sao tin "đứng riêng" lại tách cả cụm phía trước lẫn phía sau? Một tin trả lời có khung trích dẫn ở trên, một tin có ảnh thì to và bo góc theo ảnh. Dính chúng vào bong bóng chữ bên cạnh sẽ làm hỏng hình dạng "chuỗi bong bóng liền mạch" mà việc gom cụm muốn tạo ra.

#Một lượt reduce

Hàm parse duyệt danh sách tin đã sắp theo thời gian, giữ ba thứ: tin trước, cụm đang gom và danh sách item đầu ra:

messages.reduce((acc, cur, i) => {
  const split = isInDifferentGroup(cur, acc.prev, unreadMark);
  if (i === 0) acc.items = maybeAddDateBreak(cur, acc.prev, acc.items);
  if (acc.group.length > 0 && split) {
    acc.items.push(makeGroup(acc.group));
    acc.group = [];
    acc.items = maybeAddDateBreak(cur, acc.prev, acc.items);          // cách hơn 1 giờ
    acc.items = maybeAddUnreadIndicator(cur, acc.prev, acc.items, unreadMark);
  }
  acc.group.push(cur);
  if (i === messages.length - 1) acc.items.push(makeGroup(acc.group));
  return { prev: cur, group: acc.group, items: acc.items };
}, { prev: undefined, group: [], items: [] });
js

Ngưỡng vạch thời gian là dateBreakThresholdSec = 1e3 * 60 * 60. Tên biến ghi "Sec" nhưng giá trị là mili giây (một giờ). Code chạy đúng vì nó so với hiệu hai timestamp mili giây; chỉ có cái tên là sai, đủ để người sửa sau nhân thêm 1000 lần nữa.

Hai điểm đáng để ý:

  • Vạch thời gian chỉ được kiểm tra ở tin đầu tiên và ở ranh giới cụm. Code dựa vào việc 1 giờ lớn hơn 5 phút: hai tin cách nhau hơn 1 giờ chắc chắn đã bị tách cụm vì luật 5 phút. Nếu ai đó tăng GROUPING_THRESHOLD lên quá 1 giờ, vạch thời gian sẽ biến mất mà không có lỗi nào.
  • Vạch "chưa đọc" nằm sau vạch thời gian, và chỉ được chèn khi đã có tin trước (prev !== undefined). Mở một thread mà mọi tin đều chưa đọc thì không có vạch: hợp lý, vì vạch ở trên cùng không giúp gì.

#Avatar "đã xem"

getSeenHeads(participants, messages, viewerId) trả Map<messageId, avatar[]>:

const others = participants.filter((p) => p.contact_id !== viewerId);
const best = new Map();                       // contactId -> tin mới nhất đã thấy
messages.forEach((m) => {
  if (MWLSMessageParser.isBlocked(m)) return;
  others.forEach((p) => {
    const saw = m.message.h === p.contact_id || m.message.b <= p.read_watermark_timestamp_ms;
    const cur = best.get(p.id);
    if (saw && (cur === undefined || m.message.b > cur.message.b)) best.set(p.id, m);
  });
});
js

Quy tắc "tin họ tự gửi cũng tính là đã thấy" quan trọng hơn vẻ ngoài. Mốc đã đọc do server cập nhật khi người kia mở thread; nhưng nếu họ vừa nhắn trả lời thì rõ ràng họ đã thấy mọi tin trước đó, kể cả khi sự kiện "đã đọc" chưa về tới. Không có luật này, avatar của An sẽ nằm lơ lửng phía trên tin An vừa gửi.

Độ phức tạp là O(số tin × số người). Với nhóm 250 người và 200 tin đã tải thì là 50.000 phép so sánh mỗi lần render, chưa đáng lo, nhưng có cách rẻ hơn (xem phần viết lại).

flowchart TD
  A["Tin cur, tin trước prev"] --> B{"Đổi người gửi?"}
  B -- có --> N["Cụm mới"]
  B -- không --> C{"Cách prev hơn 5 phút?"}
  C -- có --> N
  C -- không --> D{"cur chuyển tiếp, hoặc cur/prev đứng riêng?"}
  D -- có --> N
  D -- không --> E{"Vạch chưa đọc nằm giữa?"}
  E -- có --> N
  E -- không --> G["Gom vào cụm hiện tại"]
  N --> T{"Cách prev hơn 1 giờ?"}
  T -- có --> TB["Chèn vạch thời gian"]
  T -- không --> U["Chèn vạch chưa đọc nếu có"]
  TB --> U

#Bên trong khung chat

Gom cụm là việc trình bày, không đổi dữ liệu. Từ một danh sách phẳng, ta sinh danh sách item hỗn hợp: vạch thời gian, vạch chưa đọc, tin hệ thống, cụm. Lớp hiển thị chỉ cần biết vị trí của tin trong cụm (đầu, giữa, cuối, hoặc một mình) để bo góc, và hiện avatar ở tin cuối cụm. Đây cũng là lý do hàm nên là hàm thuần: cùng đầu vào cho cùng đầu ra, dễ memo bằng useMemo theo danh sách tin và mốc đã đọc.

Một chi tiết hay bị bỏ qua: khi danh sách item thay đổi (tin mới gom vào cụm cũ), key của cụm phải ổn định. Dùng id tin đầu cụm làm key thì thêm tin vào cuối cụm không làm React dựng lại cả cụm. Nếu cần giữ vị trí cuộn khi tin cũ được tải thêm phía trên, xem #16 Khung chat không giật.

#Viết lại

Luật tách cụm tách riêng ra hàm, có thêm vạch khi sang ngày mới, và nói thẳng điều kiện "vạch thời gian thì cũng tách cụm" thay vì dựa vào việc 1 giờ > 5 phút:

src/lib/messageGroups.ts
/** Có cần chèn vạch thời gian phía trên `m` không. */
export function needsTimeBreak(m: ChatMessage, prev: ChatMessage | undefined, o: GroupOptions = {}) {
  if (!prev) return true;
  const gap = m.timestamp - prev.timestamp;
  return gap > (o.timeBreakMs ?? DEFAULTS.timeBreakMs) || (!!o.breakOnNewDay && !sameDay(prev.timestamp, m.timestamp));
}

/** `m` có bắt đầu cụm mới so với `prev` không. Thứ tự luật giống bản gốc. */
export function startsNewGroup(m: ChatMessage, prev: ChatMessage | undefined, o: GroupOptions = {}) {
  if (!prev) return true;
  const max = o.maxCharsInGroup ?? DEFAULTS.maxCharsInGroup;
  return (
    m.senderId !== prev.senderId ||
    m.timestamp - prev.timestamp > (o.groupGapMs ?? DEFAULTS.groupGapMs) ||
    !!m.forwarded ||
    isStandalone(m, max) ||
    isStandalone(prev, max) ||
    crosses(o.viewerReadAt, prev, m) ||
    needsTimeBreak(m, prev, o) // bản gốc dựa vào việc 1 giờ > 5 phút; ở đây nói thẳng ra
  );
}

/** Duyệt một lượt theo thời gian, trả danh sách item để render. */
export function groupMessages(messages: readonly ChatMessage[], o: GroupOptions = {}): ChatItem[] {
  const items: ChatItem[] = [];
  let current: ChatMessage[] = [];
  let prev: ChatMessage | undefined;

  const flush = () => {
    if (current.length === 0) return;
    const first = current[0];
    items.push(
      first.admin
        ? { kind: 'admin', message: first }
        : { kind: 'group', senderId: first.senderId, messages: current },
    );
    current = [];
  };

  for (const m of messages) {
    if (m.blocked) continue;
    if (startsNewGroup(m, prev, o)) {
      flush();
      if (needsTimeBreak(m, prev, o)) items.push({ kind: 'time', timestamp: m.timestamp });
      if (prev && crosses(o.viewerReadAt, prev, m)) items.push({ kind: 'unread' });
    }
    current.push(m);
    prev = m;
  }
  flush();
  return items;
}
ts

Avatar đã xem: đi ngược từ tin mới nhất, gặp tin thoả điều kiện đầu tiên là dừng. Mỗi người tốn O(khoảng cách tới tin họ đã thấy), thường chỉ vài bước:

src/lib/messageGroups.ts
/**
 * Mỗi người (trừ người đang xem) gắn vào tin MỚI NHẤT họ đã thấy:
 * tin do chính họ gửi, hoặc tin có timestamp <= readWatermark.
 * Trả Map messageId -> danh sách người.
 */
export function seenHeads(
  messages: readonly ChatMessage[],
  readers: readonly Reader[],
  viewerId: string,
): Map<string, Reader[]> {
  const heads = new Map<string, Reader[]>();
  for (const r of readers) {
    if (r.id === viewerId) continue;
    // đi ngược từ cuối: gặp tin thoả điều kiện đầu tiên là xong, O(n) mỗi người
    for (let i = messages.length - 1; i >= 0; i--) {
      const m = messages[i];
      if (m.blocked) continue;
      if (m.senderId === r.id || m.timestamp <= r.readWatermark) {
        const list = heads.get(m.id) ?? [];
        list.push(r);
        heads.set(m.id, list);
        break;
      }
    }
  }
  return heads;
}
ts
Xem toàn bộ src/lib/messageGroups.ts
src/lib/messageGroups.ts
/**
 * Gom tin nhắn thành cụm, chèn vạch thời gian / vạch "chưa đọc",
 * và tính avatar "đã xem" của từng người.
 * Ý tưởng học từ MWLSMessageGroupsParser.bs của Messenger web, viết lại từ đầu.
 */

export interface ChatMessage {
  id: string;
  senderId: string;
  /** epoch ms */
  timestamp: number;
  text?: string;
  hasAttachment?: boolean;
  /** id tin được trả lời */
  replyTo?: string;
  forwarded?: boolean;
  /** tin hệ thống: "An đã đổi tên nhóm" */
  admin?: boolean;
  /** tin của người bị chặn: không hiện, không nhận avatar đã xem */
  blocked?: boolean;
}

export interface Reader {
  id: string;
  name: string;
  /** tin có timestamp <= mốc này coi như người đó đã đọc */
  readWatermark: number;
}

export type ChatItem =
  | { kind: 'time'; timestamp: number }
  | { kind: 'unread' }
  | { kind: 'admin'; message: ChatMessage }
  | { kind: 'group'; senderId: string; messages: ChatMessage[] };

export interface GroupOptions {
  /** hai tin cùng người gửi cách nhau quá ngưỡng này thì tách cụm. Messenger: 5 phút */
  groupGapMs?: number;
  /** cách tin trước quá ngưỡng này thì chèn vạch thời gian. Messenger: 1 giờ */
  timeBreakMs?: number;
  /** chữ dài hơn ngưỡng này thì đứng riêng một cụm. Messenger: 280 */
  maxCharsInGroup?: number;
  /** mốc đã đọc của người đang xem; tin đầu tiên mới hơn mốc này có vạch "chưa đọc" phía trên */
  viewerReadAt?: number;
  /** thêm vạch khi sang ngày mới (giờ địa phương) dù chưa đủ timeBreakMs. Bản gốc không có */
  breakOnNewDay?: boolean;
}

const DEFAULTS = { groupGapMs: 5 * 60_000, timeBreakMs: 60 * 60_000, maxCharsInGroup: 280 };

/** Tin "đứng riêng": không dính vào cụm trước hay sau. */
export function isStandalone(m: ChatMessage, maxChars = DEFAULTS.maxCharsInGroup): boolean {
  return Boolean(
    m.admin || m.replyTo || m.hasAttachment || (m.text && [...m.text].length > maxChars),
  );
}

function crosses(mark: number | undefined, prev: ChatMessage, next: ChatMessage) {
  return mark !== undefined && prev.timestamp <= mark && next.timestamp > mark;
}

function sameDay(a: number, b: number) {
  return new Date(a).toDateString() === new Date(b).toDateString();
}

/** Có cần chèn vạch thời gian phía trên `m` không. */
export function needsTimeBreak(m: ChatMessage, prev: ChatMessage | undefined, o: GroupOptions = {}) {
  if (!prev) return true;
  const gap = m.timestamp - prev.timestamp;
  return gap > (o.timeBreakMs ?? DEFAULTS.timeBreakMs) || (!!o.breakOnNewDay && !sameDay(prev.timestamp, m.timestamp));
}

/** `m` có bắt đầu cụm mới so với `prev` không. Thứ tự luật giống bản gốc. */
export function startsNewGroup(m: ChatMessage, prev: ChatMessage | undefined, o: GroupOptions = {}) {
  if (!prev) return true;
  const max = o.maxCharsInGroup ?? DEFAULTS.maxCharsInGroup;
  return (
    m.senderId !== prev.senderId ||
    m.timestamp - prev.timestamp > (o.groupGapMs ?? DEFAULTS.groupGapMs) ||
    !!m.forwarded ||
    isStandalone(m, max) ||
    isStandalone(prev, max) ||
    crosses(o.viewerReadAt, prev, m) ||
    needsTimeBreak(m, prev, o) // bản gốc dựa vào việc 1 giờ > 5 phút; ở đây nói thẳng ra
  );
}

/** Duyệt một lượt theo thời gian, trả danh sách item để render. */
export function groupMessages(messages: readonly ChatMessage[], o: GroupOptions = {}): ChatItem[] {
  const items: ChatItem[] = [];
  let current: ChatMessage[] = [];
  let prev: ChatMessage | undefined;

  const flush = () => {
    if (current.length === 0) return;
    const first = current[0];
    items.push(
      first.admin
        ? { kind: 'admin', message: first }
        : { kind: 'group', senderId: first.senderId, messages: current },
    );
    current = [];
  };

  for (const m of messages) {
    if (m.blocked) continue;
    if (startsNewGroup(m, prev, o)) {
      flush();
      if (needsTimeBreak(m, prev, o)) items.push({ kind: 'time', timestamp: m.timestamp });
      if (prev && crosses(o.viewerReadAt, prev, m)) items.push({ kind: 'unread' });
    }
    current.push(m);
    prev = m;
  }
  flush();
  return items;
}

/**
 * Mỗi người (trừ người đang xem) gắn vào tin MỚI NHẤT họ đã thấy:
 * tin do chính họ gửi, hoặc tin có timestamp <= readWatermark.
 * Trả Map messageId -> danh sách người.
 */
export function seenHeads(
  messages: readonly ChatMessage[],
  readers: readonly Reader[],
  viewerId: string,
): Map<string, Reader[]> {
  const heads = new Map<string, Reader[]>();
  for (const r of readers) {
    if (r.id === viewerId) continue;
    // đi ngược từ cuối: gặp tin thoả điều kiện đầu tiên là xong, O(n) mỗi người
    for (let i = messages.length - 1; i >= 0; i--) {
      const m = messages[i];
      if (m.blocked) continue;
      if (m.senderId === r.id || m.timestamp <= r.readWatermark) {
        const list = heads.get(m.id) ?? [];
        list.push(r);
        heads.set(m.id, list);
        break;
      }
    }
  }
  return heads;
}

/** Vị trí của tin trong cụm, để bo góc bong bóng: chỉ có một tin, đầu, giữa, cuối. */
export function bubblePosition(index: number, size: number): 'single' | 'first' | 'middle' | 'last' {
  if (size === 1) return 'single';
  if (index === 0) return 'first';
  return index === size - 1 ? 'last' : 'middle';
}
ts

#Đi qua code

  • isStandalone gom bốn loại tin không dính với ai: tin hệ thống, tin trả lời, tin có tệp, tin dài. Độ dài đếm theo code point ([...text].length) để 280 emoji không bị tính thành 560.
  • crosses(mark, prev, m) là một hàm dùng cho cả luật tách cụm lẫn việc chèn vạch chưa đọc, nên hai chỗ không thể lệch nhau.
  • groupMessages chỉ đẩy cụm ra khi gặp ranh giới (flush), rồi mới chèn vạch thời gian và vạch chưa đọc. Thứ tự vạch giống bản gốc.
  • Tin hệ thống luôn đứng riêng, nên cụm của nó chỉ có một tin; flush đổi nó thành item admin để hiển thị ở giữa khung.
  • Tin bị chặn (blocked) bị bỏ qua ở cả hai hàm: không hiện, không giữ avatar đã xem.
  • bubblePosition trả single | first | middle | last cho CSS bo góc.

Khác bản gốc:

  • Vạch thời gian là điều kiện tách cụm tường minh, không phụ thuộc quan hệ giữa hai ngưỡng.
  • Có breakOnNewDay: hai tin 23:57 và 00:05 chỉ cách nhau 8 phút, bản gốc không chèn vạch, người đọc không biết đã sang ngày khác.
  • Tìm avatar đã xem bằng một lượt đi ngược cho mỗi người, thay vì so mọi tin với mọi người.
  • Độ dài tin đếm theo code point; bản gốc dùng .length của chuỗi (đơn vị UTF-16).
  • Không có tiêu đề cụm (tên người gửi trong nhóm, "Bạn đã trả lời An"); bản gốc tính nó trong getGroupTitle. Demo tự hiện tên ở lớp giao diện.

#Tại sao không làm cách khác?

  • Gom theo người gửi thôi: hai tin của An cách nhau ba tiếng sẽ dính vào nhau, mất mạch hội thoại. Ngưỡng thời gian là bắt buộc.
  • Gom bằng CSS (:has() hoặc selector anh em để ẩn avatar khi tin sau cùng người gửi): làm được phần hình, nhưng không biết khoảng thời gian, tin trả lời hay vạch chưa đọc. Dữ liệu cần để quyết định nằm trong JavaScript.
  • Lưu cụm ở server: cụm phụ thuộc mốc đã đọc của từng người xem, nên mỗi người một kết quả. Tính ở client từ dữ liệu thô là đúng chỗ.
  • Avatar đã xem cho mọi tin đã đọc: nhóm 20 người sẽ có 20 avatar lặp lại dưới mọi tin. Chỉ đặt ở tin mới nhất mỗi người đã thấy là đủ thông tin và gọn.

#Edge case và cạm bẫy

  • Thứ tự tin: thuật toán giả định danh sách đã sắp theo timestamp. Tin gửi đi từ máy mình có timestamp tạm ở client, sau đó server trả timestamp thật; nếu đồng hồ máy lệch, tin có thể nhảy chỗ và cụm thay đổi. Sắp xếp lại sau khi nhận timestamp server.
  • Mốc đã đọc nằm giữa hai tin cùng timestamp: điều kiện prev <= mark < cur không bao giờ đúng khi prev.timestamp === cur.timestamp. Hiếm, nhưng tin gửi theo lô có thể trùng mili giây.
  • Người vừa rời nhóm: vẫn có trong danh sách participants cũ thì vẫn có avatar đã xem. Bản gốc lọc theo danh sách hiện tại do caller truyền vào; bản viết lại cũng vậy.
  • Múi giờ: breakOnNewDay dùng giờ địa phương của trình duyệt. Hai người ở hai múi giờ thấy vạch ở chỗ khác nhau, đúng như mong đợi.
  • Tin dài nhưng chỉ có link: một URL 300 ký tự cũng thành tin đứng riêng. Có thể muốn đếm độ dài sau khi rút gọn link.

#Dùng trong dự án

import { useMemo } from 'react';
import { bubblePosition, groupMessages, seenHeads } from './messageGroups';

function Thread({ messages, members, me, myReadAt }) {
  const items = useMemo(
    () => groupMessages(messages, { viewerReadAt: myReadAt, breakOnNewDay: true }),
    [messages, myReadAt],
  );
  const heads = useMemo(() => seenHeads(messages, members, me), [messages, members, me]);

  return items.map((item, idx) =>
    item.kind === 'group' ? (
      <Group key={item.messages[0].id} mine={item.senderId === me}>
        {item.messages.map((m, i) => (
          <Bubble key={m.id} pos={bubblePosition(i, item.messages.length)} seen={heads.get(m.id)} {...m} />
        ))}
      </Group>
    ) : (
      <Divider key={`${item.kind}-${idx}`} item={item} />
    ),
  );
}
tsx

#Kiểm thử

Ví dụ test (bản phác, không có trong repo)
import { describe, expect, it } from 'vitest';
import { groupMessages, seenHeads, type ChatMessage } from './messageGroups';

const at = (min: number) => Date.UTC(2026, 0, 1, 10, 0) + min * 60_000;
const msg = (id: string, senderId: string, min: number, extra: Partial<ChatMessage> = {}): ChatMessage => ({
  id, senderId, timestamp: at(min), text: id, ...extra,
});
const shape = (items: ReturnType<typeof groupMessages>) =>
  items.map((i) => (i.kind === 'group' ? i.messages.map((m) => m.id).join('+') : i.kind));

describe('groupMessages', () => {
  it('gom cùng người gửi trong 5 phút, tách khi đổi người', () => {
    const items = groupMessages([msg('a1', 'a', 0), msg('a2', 'a', 4), msg('b1', 'b', 5)]);
    expect(shape(items)).toEqual(['time', 'a1+a2', 'b1']);
  });

  it('cách quá 5 phút thì tách, quá 1 giờ thì có vạch thời gian', () => {
    const items = groupMessages([msg('a1', 'a', 0), msg('a2', 'a', 6), msg('a3', 'a', 70)]);
    expect(shape(items)).toEqual(['time', 'a1', 'a2', 'time', 'a3']);
  });

  it('tin trả lời và tin có ảnh đứng riêng', () => {
    const items = groupMessages([
      msg('a1', 'a', 0), msg('a2', 'a', 1, { replyTo: 'x' }), msg('a3', 'a', 2), msg('a4', 'a', 3, { hasAttachment: true }),
    ]);
    expect(shape(items)).toEqual(['time', 'a1', 'a2', 'a3', 'a4']);
  });

  it('vạch chưa đọc tách cụm và nằm đúng chỗ', () => {
    const items = groupMessages([msg('a1', 'a', 0), msg('a2', 'a', 1), msg('a3', 'a', 2)], { viewerReadAt: at(1) });
    expect(shape(items)).toEqual(['time', 'a1+a2', 'unread', 'a3']);
  });

  it('280 emoji vẫn là tin ngắn (đếm code point)', () => {
    const items = groupMessages([msg('a1', 'a', 0), msg('a2', 'a', 1, { text: '😀'.repeat(280) })]);
    expect(shape(items)).toEqual(['time', 'a1+a2']);
  });
});

describe('seenHeads', () => {
  const list = [msg('a1', 'a', 0), msg('b1', 'b', 1), msg('a2', 'a', 2)];
  it('đặt ở tin mới nhất đã đọc', () => {
    const h = seenHeads(list, [{ id: 'b', name: 'B', readWatermark: at(0) }], 'me');
    expect([...h.keys()]).toEqual(['b1']); // b1 do chính B gửi, mới hơn a1
  });
  it('bỏ qua người đang xem', () => {
    expect(seenHeads(list, [{ id: 'me', name: 'Me', readWatermark: at(9) }], 'me').size).toBe(0);
  });
});
ts

#So với thư viện và chuẩn web

  • Stream Chat React, CometChat, Sendbird UI Kit: đều có gom tin theo người gửi và khoảng thời gian (Stream dùng prop kiểu groupStyles để đánh dấu top/middle/bottom/single, cùng ý tưởng với bubblePosition). Ngưỡng và luật "đứng riêng" khác nhau giữa các thư viện; đọc tài liệu của bản đang dùng.
  • Intl.DateTimeFormat và Intl.RelativeTimeFormat: dùng để in vạch thời gian theo ngôn ngữ người dùng ("Thứ Ba 22:00", "hôm qua"), có ở mọi trình duyệt chính.
  • CSS :has(): có thể ẩn avatar ở tin không phải cuối cụm nếu DOM đã gom sẵn theo cụm, nhưng không thay được logic quyết định cụm.
  • Bài liên quan: #42 Thread list theo mốc thời gian (mốc đã đọc, presence), #16 Khung chat không giật.

Bài này thuộc series Học từ mã nguồn Facebook (106 bài, đọc theo thứ tự).

Xem nhanh danh sách bài trong series

Phần 1

Phần 2

Phần 3

Phần 4–6

Phần 7–10

Phần 11–15

Code + demo: examples/facebook-patterns · Mở trên StackBlitz