채팅을 조금 더 친근하게 만들고, 채팅 채널에 몇 명이 있는지 더 쉽게 확인할 수 있도록 하려고 했습니다. 지금까지는 이 해킹적인 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();
}
});
결과:

다만, 이것이 기본 구현으로 적합한지 의문이 듭니다. 다음 방식보다 훨씬 쉽게 발견할 수 있기 때문입니다:
- 채널 헤더를 클릭
- 멤버 탭을 클릭
또한, 멤버 카운터가 헤더에 표시된다면 설정의 탭을 제거할 수도 있습니다. 헤더에 이미 해당 정보가 포함되어 있기 때문입니다.
