# Member count in chat channels

**URL:** https://meta.discourse.org/t/member-count-in-chat-channels/386780
**Category:** Feature
**Tags:** chat
**Created:** [October 26, 2025, 10:45pm UTC](https://meta.discourse.org/t/member-count-in-chat-channels/386780 "2025-10-26T22:45:41Z")
**Posts on this page:** 1
**Page:** 1

<div class="post-metadata">

### Author: ![Anton\_Akhmerov](https://sea3.discourse-cdn.com/meta/user_avatar/meta.discourse.org/anton_akhmerov/32/169548_2.png) [@Anton\_Akhmerov](https://meta.discourse.org/u/Anton_Akhmerov)
#### Post date: [October 26, 2025, 10:45pm UTC](https://meta.discourse.org/t/member-count-in-chat-channels/386780/1 "2025-10-26T22:45:41Z")

</div>

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 (licensed CC-0 if anybody finds it useful).

> **Extension source code**
>
> CSS
> 
> ```scss
> .chat-channel-member-count {
> color: var(--primary-medium);
> .d-icon { display: inline-flex; }
> &:hover { color: var(--primary-high); text-decoration: none; }
> }
> 
> ```
> 
> JS
> 
> ```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:  
 ![image](https://global.discourse-cdn.com/meta/original/4X/f/c/b/fcb57e474787a14a82c0bfae7f970e5512ce34e3.png)

I wonder, however, if it makes sense as a default implementation. It is easier to discover than:

1. Clicking on the channel header
2. 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.

 ![image](https://global.discourse-cdn.com/meta/original/4X/6/d/6/6d6232193e277f24c3cc0ed2dd8e200a9c01a74b.png)
