チャットチャンネルのメンバー数

チャットをよりフレンドリーにし、チャットチャンネルに何人いるかを見つけやすくしようとしました。これまでは、このハッキーな雰囲気でコード化されたテーマコンポーネント(誰かが役立つと思えば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 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();
  }
});

結果:
image

しかし、これがデフォルトの実装として理にかなっているかどうか疑問に思っています。これまでのものよりも見つけやすいです。

  1. チャンネルヘッダーをクリックする
  2. メンバータブをクリックする

さらに、メンバーカウンターを配置することで、すでにヘッダーに含まれているタブを削除することもできます。

「いいね!」 4