I was trying to make the chat a bit more friendly and make it easier to find how many people are there in the chat channel. So far I achieved it with this hacky vibe-coded theme component.
Extension source code
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 router
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 = "Channel members";
a.setAttribute("data-auto-route", "true"); // cosmetic; we handle routing below
Object.assign(a.style, {
marginLeft: ".5rem",
display: "inline-flex",
alignItems: "center",
gap: ".25rem",
pointerEvents: "auto",
position: "relative",
zIndex: "2",
});
// Left click -> SPA route; modifier clicks behave normally
a.addEventListener("click", (e) => {
const modified = e.metaKey || e.ctrlKey || e.shiftKey || e.altKey || e.button !== 0;
if (modified) return; // let browser open new tab/window
e.preventDefault();
e.stopPropagation(); // don't open channel settings
DiscourseURL.routeTo(href); // <- SPA navigation (no reload)
});
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);
// Keep badge if header re-renders
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;
}
// React to route changes
api.onPageChange(() => {
const si = getSlugId();
const id = si?.id || null;
if (id && id !== currentId) {
currentId = id;
mount();
} else if (!id) {
currentId = null;
teardown();
}
});
// Initial render if already on a channel
const first = getSlugId();
if (first) {
currentId = first.id;
mount();
}
});
Result:

I wonder, however, if it makes sense as a default implementation. It is easier to discover than:
- Clicking on the channel header
- Clicking on the members tab
Furthermore, with the member counter in place, one could remove the tabs from the settings since they are already contained in the header.
