Mitgliederzahl in Chatkanälen

Ich habe versucht, den Chat etwas freundlicher zu gestalten und es einfacher zu machen, herauszufinden, wie viele Personen sich im Chat-Kanal befinden. Bisher habe ich dies mit dieser “hacky vibe-coded” Theme-Komponente erreicht (lizenzfrei, CC-0, falls jemand sie nützlich findet).

Erweiterungsquellcode

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();
  }
});

Ergebnis:
image

Ich frage mich jedoch, ob dies als Standardimplementierung sinnvoll ist. Es ist einfacher zu entdecken als:

  1. Klick auf die Kanalüberschrift
  2. Klick auf den Reiter Mitglieder

Darüber hinaus könnte man mit dem Mitgliederzähler die Reiter in den Einstellungen entfernen, da sie bereits in der Kopfzeile enthalten sind.

4 „Gefällt mir“