채팅 채널의 멤버 수

채팅을 조금 더 친근하게 만들고, 채팅 채널에 몇 명이 있는지 더 쉽게 확인할 수 있도록 하려고 했습니다. 지금까지는 이 해킹적인 vibe-coded 테마 컴포넌트로 구현해 봤습니다(누군가 유용하게 쓰신다면 CC-0 라이선스로 공개합니다).

확장 소스 코드

CSS

.chat-channel-member-count {
  color: var(--primary-medium);
  .d-icon { display: inline-flex; }
  &:hover { color: var(--primary-high); text-decoration: none; }
}

JS

// javascripts/discourse/api-initializers/chat-member-count.js
import { apiInitializer } from "discourse/lib/api";
import { ajax } from "discourse/lib/ajax";
import { iconHTML } from "discourse-common/lib/icon-library";
import DiscourseURL from "discourse/lib/url"; // <- SPA 라우터

export default apiInitializer("1.8.0", (api) => {
  let badgeEl = null;
  let mo = null;
  let currentId = null;

  const pathMatch = () => window.location.pathname.match(/\/chat\/c\/([^/]+)\/(\d+)/);
  const getSlugId = () => {
    const m = pathMatch();
    return m ? { slug: m[1], id: parseInt(m[2], 10) } : null;
  };
  const titleEl = () => document.querySelector(".chat-channel-title");
  const makeHref = ({ slug, id }) => `/chat/c/${slug}/${id}/info/members`;

  async function waitForTitle(maxMs = 10000) {
    if (titleEl()) return titleEl();
    const start = performance.now();
    return new Promise((resolve) => {
      const obs = new MutationObserver(() => {
        if (titleEl() || performance.now() - start > maxMs) {
          obs.disconnect();
          resolve(titleEl() || null);
        }
      });
      obs.observe(document.body, { childList: true, subtree: true });
    });
  }

  async function fetchMemberCount(id) {
    try {
      const ch = await ajax(`/chat/api/channels/${id}`);
      const n =
        ch?.user_count ??
        ch?.memberships_count ??
        ch?.users_count ??
        ch?.stats?.members_count;
      if (Number.isFinite(n)) return n;
    } catch {}
    try {
      const res = await ajax(`/chat/api/channels/${id}/memberships`);
      const metaTotal = res?.meta?.total ?? res?.total;
      if (Number.isFinite(metaTotal)) return metaTotal;
      return Array.isArray(res?.memberships) ? res.memberships.length : null;
    } catch {
      return null;
    }
  }

  function renderBadge(href, count) {
    const a = document.createElement("a");
    a.href = href;
    a.className = "chat-channel-member-count";
    a.title = "채널 멤버";
    a.setAttribute("data-auto-route", "true"); // 장식용; 라우팅은 아래에서 처리
    Object.assign(a.style, {
      marginLeft: ".5rem",
      display: "inline-flex",
      alignItems: "center",
      gap: ".25rem",
      pointerEvents: "auto",
      position: "relative",
      zIndex: "2",
    });

    // 왼쪽 클릭 -> SPA 라우팅; 수정키와 함께 클릭하면 정상 동작
    a.addEventListener("click", (e) => {
      const modified = e.metaKey || e.ctrlKey || e.shiftKey || e.altKey || e.button !== 0;
      if (modified) return; // 브라우저가 새 탭/창을 열도록 허용
      e.preventDefault();
      e.stopPropagation(); // 채널 설정이 열리지 않도록 방지
      DiscourseURL.routeTo(href); // <- SPA 내비게이션 (새로고침 없음)
    });

    const iconWrap = document.createElement("span");
    iconWrap.className = "d-icon";
    iconWrap.innerHTML = iconHTML("user");

    const num = document.createElement("span");
    num.textContent = Number.isFinite(count) ? String(count) : "–";

    a.appendChild(iconWrap);
    a.appendChild(num);
    return a;
  }

  function removeBadge() {
    badgeEl?.remove?.();
    badgeEl = null;
  }

  async function mount() {
    const si = getSlugId();
    if (!si) return;
    const el = await waitForTitle();
    if (!el) return;

    removeBadge();
    const count = await fetchMemberCount(si.id);
    badgeEl = renderBadge(makeHref(si), count);
    el.insertAdjacentElement("afterend", badgeEl);

    // 헤더가 다시 렌더링되어도 배지가 유지되도록
    mo?.disconnect?.();
    mo = new MutationObserver(() => {
      if (badgeEl && !document.body.contains(badgeEl)) {
        const t = titleEl();
        if (t) t.insertAdjacentElement("afterend", badgeEl);
      }
    });
    mo.observe(el.closest(".chat-header, body"), { childList: true, subtree: true });
  }

  function teardown() {
    removeBadge();
    mo?.disconnect?.();
    mo = null;
  }

  // 라우트 변경에 반응
  api.onPageChange(() => {
    const si = getSlugId();
    const id = si?.id || null;
    if (id && id !== currentId) {
      currentId = id;
      mount();
    } else if (!id) {
      currentId = null;
      teardown();
    }
  });

  // 이미 채널에 있는 경우 초기 렌더링
  const first = getSlugId();
  if (first) {
    currentId = first.id;
    mount();
  }
});

결과:
image

다만, 이것이 기본 구현으로 적합한지 의문이 듭니다. 다음 방식보다 훨씬 쉽게 발견할 수 있기 때문입니다:

  1. 채널 헤더를 클릭
  2. 멤버 탭을 클릭

또한, 멤버 카운터가 헤더에 표시된다면 설정의 탭을 제거할 수도 있습니다. 헤더에 이미 해당 정보가 포함되어 있기 때문입니다.

4개의 좋아요