// ===========================================================
// Community (11 Oct 2026): the activity feed and the collectors grid.
//
// /community             the feed (the landing tab)
// /community/collectors  one tile per collector
// /leaderboards          the boards, unchanged, wearing the same three tabs
//
// Public: works signed out, like the leaderboards. Both tabs read ONE jsonb row
// each (community_feed, community_collectors; their current bodies are in
// supabase/migrations/community_qa_fixes.sql), so PostgREST's 1,000-row cap
// never applies.
//
// THE FEED IS DERIVED, NOT STORED. A post is a collector's day of adds, a day's
// pressings that moved up a rarity tier and held, or a new weekly dive. Each has
// a stable `key`, which is what a like attaches to (a shelf's is shelf:<user>).
//
// IDENTITY. The server withholds display_name and member_since for an anonymous
// collector; lbLabel() is the one place a name is decided, here as on the
// boards. Nothing on this page may say or imply when an anonymous collector
// joined: "started a shelf" and "Founding member" are for named collectors only.
//
// Reworked 12 Oct 2026 after a QA of the first day (two independent reviews and
// live testing). What each change answers is said where it is made.
//
// Globals in: React, BBR_supabase, Sleeve, lbLabel, lbFetchCover,
// collectorTierName, bbrDiveFor, BBR_track, __nav, bbrOverlayPortal,
// CollectorBadge, __bbrRequireAuth. Exposes window.Community, CommunityTabs,
// CommunityNavBadge, CommunityProfileHero (the top of a shelf page),
// CommunityActivity (a collector's own posts and adds, under it) and CmtyAvatar.
//
// 13 Oct 2026: collectors write posts (text, up to four photos or a video, a
// record), comments can be liked and replied to, a picture can be an uploaded
// photo, and a Grail or Rare on the shelf puts a ring round it. Posts are read
// live, not from the cache (supabase/migrations/community_posts.sql).
// ===========================================================
const { useState: useCmState, useEffect: useCmEffect, useMemo: useCmMemo, useRef: useCmRef } = React;

const CMTY_PAGE = 30;
const CMTY_POLL_MS = 60000;
const CMTY_LOAD_ERR = "We couldn’t load this just now. Try again in a moment.";
const CMTY_TIER_LABEL = { common: "Common", scarce: "Scarce", rare: "Rare", grail: "Grail" };
// Launch month. A named collector who joined in it is a founding member.
const CMTY_FOUNDING_BEFORE = "2026-07-01";

function cmtyGbp(n) { return "£" + Math.round(Number(n) || 0).toLocaleString("en-GB"); }
function cmtyNum(n) { return (Number(n) || 0).toLocaleString("en-GB"); }
function cmtyPlural(n, one, many) { return cmtyNum(n) + " " + (Number(n) === 1 ? one : (many || one + "s")); }
function cmtyName(actor, meId) {
  const me = !!(meId && actor && actor.user_id === meId);
  return window.lbLabel ? window.lbLabel(actor, me) : (actor && actor.display_name) || "Collector";
}
function cmtyInitials(actor) {
  const s = actor && actor.is_public ? (actor.display_name || "") : (actor && actor.anon_alias) || "";
  // By code point, and from letters and digits where a name has any: a name
  // that opens with an emoji gave half a surrogate pair, drawn as a broken
  // glyph, and one in quotes or brackets gave the punctuation.
  const lets = (w) => Array.from(w).filter((c) => /[\p{L}\p{N}]/u.test(c));
  // A username is one word to a space-splitter: crate_digger is "CD", not "CR".
  const words = s.trim().split(/[\s_.\-]+/).map(lets).filter((l) => l.length);
  if (!words.length) return Array.from(s.trim())[0] || "?";
  return (words.length > 1 ? words[0][0] + words[words.length - 1][0] : words[0].slice(0, 2).join("")).toUpperCase();
}
function cmtySince(iso) {
  const d = String(iso).slice(0, 10).split("-");
  const mo = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"][parseInt(d[1], 10) - 1] || "";
  return mo + " " + d[0];
}
function cmtyLevel(count) { return window.collectorTierName ? window.collectorTierName(Number(count) || 0) : ""; }
function cmtyTier(t) { return CMTY_TIER_LABEL[t] || t; }

// Days are the reader's own: a post from late last night reads "Yesterday".
function cmtyDayKey(iso) { const d = new Date(iso); return d.getFullYear() + "-" + d.getMonth() + "-" + d.getDate(); }
function cmtyDayLabel(iso) {
  const d = new Date(iso), now = new Date();
  const start = (x) => new Date(x.getFullYear(), x.getMonth(), x.getDate()).getTime();
  const diff = Math.round((start(now) - start(d)) / 86400000);
  const date = d.toLocaleDateString("en-GB", { day: "numeric", month: "short", year: d.getFullYear() === now.getFullYear() ? undefined : "numeric" });
  if (diff === 0) return "Today · " + date;
  if (diff === 1) return "Yesterday · " + date;
  return d.toLocaleDateString("en-GB", { weekday: "long" }) + " · " + date;
}
function cmtyAgo(iso) {
  if (!iso) return "a while ago";
  const days = Math.floor((Date.now() - new Date(iso).getTime()) / 86400000);
  if (days < 1) return "today";
  if (days < 2) return "yesterday";
  if (days < 14) return days + " days ago";
  if (days < 60) return Math.floor(days / 7) + " weeks ago";
  return Math.floor(days / 30) + " months ago";
}

// "12 min ago", "3 hours ago", then the clock time on older days.
function cmtyWhen(iso) {
  const d = new Date(iso), mins = Math.floor((Date.now() - d.getTime()) / 60000);
  if (mins < 1) return "just now";
  if (mins < 60) return mins + " min ago";
  if (mins < 60 * 24) { const h = Math.floor(mins / 60); return h + (h === 1 ? " hour ago" : " hours ago"); }
  return d.toLocaleTimeString("en-GB", { hour: "2-digit", minute: "2-digit" });
}
// One colour per genre, the same on every tile, so a shelf's mix reads at a
// glance and two shelves can be compared without reading a word. Every genre
// canonical_genre() produces has its own: four real ones had none and drew as
// the same grey side by side (QA, 12 Oct 2026).
const CMTY_GENRE_HUE = {
  "Rock": "#b4472e", "Electronic": "#2f5d8a", "Pop": "#c58a1c", "Jazz": "#3d6b52", "Soul / R&B": "#7a3f6b",
  "Hip-Hop": "#2b2b2b", "Folk / Country": "#8a6a3a", "Stage & Screen": "#5a6b7a", "Classical": "#6b5a8a",
  "Reggae": "#4f7a2f", "Reggae / Dub": "#4f7a2f", "Blues": "#35507a", "Latin": "#a5552b",
  "Punk / Indie": "#a23a6a", "Metal": "#4a4a55", "Children's": "#d0a23c", "Brass & Military": "#7a5a2a",
  "Krautrock": "#2f7a78", "Ambient": "#7f93a8",
};
function cmtyGenreHue(g) { return CMTY_GENRE_HUE[g] || "#8a8072"; }

// ---- cover lookups, bounded ---------------------------------------------------
// A record with no cover from the server asks /api/cover, which costs one or
// two Discogs calls against a limit of about sixty a minute for the whole site,
// and a miss is cached for hours. The first version asked for every such record
// the moment its sleeve mounted: a cold visitor could fire 150 lookups before
// any answered, the Collectors tab 400, and each of the wall's records three
// times over (QA, 12 Oct 2026). Now a sleeve asks only once it is near the
// screen, at most four lookups are in flight, and lbFetchCover() shares one
// request between everything asking for the same record.
const CMTY_COVER_MAX = 4;
const cmtyCoverQ = { active: 0, wait: [] };
function cmtyCoverPump() {
  while (cmtyCoverQ.active < CMTY_COVER_MAX && cmtyCoverQ.wait.length) {
    const job = cmtyCoverQ.wait.shift();
    if (!job.live()) continue;                       // its sleeve has gone
    cmtyCoverQ.active++;
    Promise.resolve(window.lbFetchCover(job.item)).then((u) => u, () => null).then((u) => {
      cmtyCoverQ.active--;
      job.done(u);
      cmtyCoverPump();
    });
  }
}
function cmtyCoverAsk(item, live, done) { cmtyCoverQ.wait.push({ item, live, done }); cmtyCoverPump(); }

let cmtyIO = null;
const cmtyIOCbs = new WeakMap();
// Run cb once, when el first comes within a screen-and-a-bit of the viewport.
function cmtyWhenVisible(el, cb) {
  if (!("IntersectionObserver" in window)) { cb(); return () => {}; }
  if (!cmtyIO) {
    cmtyIO = new IntersectionObserver((entries) => {
      entries.forEach((en) => {
        if (!en.isIntersecting) return;
        const f = cmtyIOCbs.get(en.target);
        cmtyIOCbs.delete(en.target);
        cmtyIO.unobserve(en.target);
        if (f) f();
      });
    }, { rootMargin: "300px 0px" });
  }
  cmtyIOCbs.set(el, cb);
  cmtyIO.observe(el);
  return () => { cmtyIOCbs.delete(el); cmtyIO.unobserve(el); };
}

// A record's cover as a URL, looked up only when it has to be (see above): a
// dive has its own file, the server sends a cached cover where it has one, and
// anything else waits until the element is near the screen. Returns [url, ref];
// the ref goes on whatever box the picture sits in.
function useCmtyCover(item) {
  const known = item ? (item.slug ? "/covers/" + item.slug + ".jpg" : (item.cover || null)) : null;
  const [url, setUrl] = useCmState(known);
  const ref = useCmRef(null);
  useCmEffect(() => {
    setUrl(known);
    if (!item || known || !window.lbFetchCover || !ref.current) return;
    let live = true;
    const stop = cmtyWhenVisible(ref.current, () => cmtyCoverAsk(item, () => live, (u) => { if (live && u) setUrl(u); }));
    return () => { live = false; stop(); };
  }, [known, item && item.artist, item && item.title]);
  return [url, ref];
}

// A sleeve for a record the feed names. The span is the thing the observer watches.
function CmtySleeve({ item, size }) {
  const [url, ref] = useCmtyCover(item);
  const album = { title: item.title, artist: item.artist, year: item.year, rank: -1, slug: item.slug || null };
  if (url) album.coverUrl = url;
  return <span className="cmty-sv" ref={ref}>{window.Sleeve ? <Sleeve album={album} size={size} /> : null}</span>;
}

// ---- avatars ----------------------------------------------------------------
// A collector's picture is a record from their own shelf, a drawn record
// label in a colour of their choosing with their initials (12 Oct 2026), or,
// since 13 Oct 2026, a photo they upload (Ric asked; it is moderated as
// comments are). The server decides which (community_avatar() in
// supabase/migrations/community_profile.sql): what they chose, else the first
// of their front-ten picks, else their most valuable record, else nothing, and
// then the initials stand alone as they always did.
//
// Twelve colours, in the order of the genres they are suggested for. The same
// hues as the genre bars, so a jazz collector's label and the jazz in their
// mix are one colour.
const CMTY_LABEL_HUES = ["#b4472e", "#2f5d8a", "#c58a1c", "#3d6b52", "#7a3f6b", "#2b2b2b", "#8a6a3a", "#5a6b7a", "#6b5a8a", "#4f7a2f", "#35507a", "#a23a6a"];
const CMTY_LABEL_GENRES = ["Rock", "Electronic", "Pop", "Jazz", "Soul / R&B", "Hip-Hop", "Folk / Country", "Stage & Screen", "Classical", "Reggae", "Blues", "Punk / Indie"];
const CMTY_LABEL_NAMES = ["Ring", "Split", "Grooves", "Sunburst", "Stripe", "Rim"];
function cmtyHueForGenre(g) { const i = CMTY_LABEL_GENRES.indexOf(g); return i < 0 ? 0 : i; }

// The label itself, drawn. Six layouts, none of them any real label's.
function CmtyLabelArt({ pattern, hue, text }) {
  const c = CMTY_LABEL_HUES[Number(hue)] || CMTY_LABEL_HUES[0];
  const cream = "#f3ede1", ink = "#17130f";
  const p = Number(pattern) || 1;
  // One id per drawing: the same label twice on a page (the chooser over the
  // header) shared an id, and a clip path that resolves to a hidden twin does
  // not clip.
  const rid = React.useId ? React.useId() : "";
  const id = "cl" + p + "-" + (Number(hue) || 0) + "-" + rid.replace(/[^a-zA-Z0-9]/g, "");
  let art, fg = cream;
  if (p === 2) {
    art = <React.Fragment><rect width="100" height="100" fill={cream} /><rect width="100" height="52" fill={c} />
      <rect x="20" y="64" width="60" height="2" fill={ink} opacity=".35" /><rect x="28" y="72" width="44" height="2" fill={ink} opacity=".25" /></React.Fragment>;
  } else if (p === 3) {
    art = <React.Fragment><rect width="100" height="100" fill={c} />
      {[46, 38, 30].map((r) => <circle key={r} cx="50" cy="50" r={r} fill="none" stroke="#fff" strokeWidth="2.5" opacity=".28" />)}</React.Fragment>;
  } else if (p === 4) {
    art = <React.Fragment><rect width="100" height="100" fill={c} />
      {Array.from({ length: 12 }, (_, i) => i).filter((i) => i % 2 === 0).map((i) => {
        const a0 = (i * 30 - 90) * Math.PI / 180, a1 = ((i + 1) * 30 - 90) * Math.PI / 180;
        return <path key={i} d={"M50 50 L" + (50 + 70 * Math.cos(a0)) + " " + (50 + 70 * Math.sin(a0)) + " L" + (50 + 70 * Math.cos(a1)) + " " + (50 + 70 * Math.sin(a1)) + " Z"} fill="#fff" opacity=".2" />;
      })}
      <circle cx="50" cy="50" r="24" fill={c} /></React.Fragment>;
  } else if (p === 5) {
    art = <React.Fragment><rect width="100" height="100" fill={c} />
      <path d="M-10 78 L78 -10 L104 -10 L-10 104 Z" fill="#fff" opacity=".22" /><path d="M20 110 L110 20 L110 34 L34 110 Z" fill="#000" opacity=".16" /></React.Fragment>;
  } else if (p === 6) {
    fg = c;
    art = <React.Fragment><rect width="100" height="100" fill={cream} /><circle cx="50" cy="50" r="46" fill="none" stroke={c} strokeWidth="8" /></React.Fragment>;
  } else {
    art = <React.Fragment><rect width="100" height="100" fill={c} /><circle cx="50" cy="50" r="42" fill="none" stroke={cream} strokeWidth="1.6" opacity=".7" /></React.Fragment>;
  }
  const t = Array.from(String(text || "")).slice(0, 2);
  return (
    <svg viewBox="0 0 100 100" width="100%" height="100%" aria-hidden="true" focusable="false">
      <defs><clipPath id={id}><circle cx="50" cy="50" r="50" /></clipPath></defs>
      <g clipPath={"url(#" + id + ")"}>{art}</g>
      <text x="50" y={p === 2 ? 38 : 51} textAnchor="middle" dominantBaseline="middle" fill={fg}
        style={{ font: "400 " + (t.length > 1 ? 34 : 40) + "px 'DM Serif Display', Georgia, serif" }}>{t.join("")}</text>
    </svg>
  );
}

// A cover as a round picture. The initials are drawn first and the picture is
// laid over them once it has actually loaded, so a slow or missing image is a
// collector's initials, never an empty circle.
function CmtyCoverAvatar({ actor, item, style, className, title }) {
  const [url, ref] = useCmtyCover(item);
  const [state, setState] = useCmState("wait");      // wait | ok | bad
  useCmEffect(() => { setState("wait"); }, [url]);
  const ok = !!url && state === "ok";
  return (
    <span className={className + (ok ? " cover" : "") + (actor && actor.is_public ? "" : " anon")} style={style} ref={ref} aria-hidden="true" title={title}>
      {cmtyInitials(actor)}
      {url && state !== "bad"
        ? <img src={url} alt="" loading="lazy" decoding="async" className={ok ? "on" : ""}
            ref={(el) => { if (el && el.complete && el.naturalWidth > 0 && state === "wait") setState("ok"); }}
            onLoad={() => setState("ok")} onError={() => setState("bad")} />
        : null}
    </span>
  );
}

// THE RING (13 Oct 2026, Ric): gold round the picture of a collector who owns
// a Grail pressing, red for a Rare, whichever is their top tier, everywhere the
// picture appears. The server works it out as the shelf counts tiers
// (community_ring() in supabase/migrations/community_posts.sql).
const CMTY_RING_TITLE = { grail: "Owns a Grail pressing", rare: "Owns a Rare pressing" };
function cmtyRing(actor) { const r = actor && actor.ring; return r === "grail" || r === "rare" ? r : null; }

// `avatar` overrides the actor's own (the picker previews a choice that way).
// `disc` draws it as the label of a record, for the top of a profile. `bare`
// leaves the ring off (the disc wears it instead).
function CmtyAvatar({ actor, size, avatar, disc, bare }) {
  const av = avatar !== undefined ? avatar : ((actor && actor.avatar) || null);
  const ring = bare ? null : cmtyRing(actor);
  const rc = ring ? " ring-" + ring : "";
  const title = ring ? CMTY_RING_TITLE[ring] : undefined;
  if (disc) {
    const s = size || 112;
    return (
      <span className={"cmty-disc" + rc} style={{ width: s, height: s }} aria-hidden="true" title={title}>
        <CmtyAvatar actor={actor} avatar={av} size={Math.round(s * (av && av.kind === "photo" ? 0.9 : 0.64))} bare />
        <i />
      </span>
    );
  }
  const style = size ? { width: size, height: size, fontSize: Math.round(size * 0.34) } : undefined;
  if (av && av.kind === "label") {
    return <span className={"cmty-av label" + rc} style={style} aria-hidden="true" title={title}><CmtyLabelArt pattern={av.pattern} hue={av.hue} text={cmtyInitials(actor)} /></span>;
  }
  if (av && av.kind === "cover") return <CmtyCoverAvatar actor={actor} item={av} style={style} className={"cmty-av" + rc} title={title} />;
  // An uploaded photo: drawn as a cover is, over the initials.
  if (av && av.kind === "photo") return <CmtyCoverAvatar actor={actor} item={{ cover: av.url || cmtyMediaUrl(av.path) }} style={style} className={"cmty-av photo" + rc} title={title} />;
  return (
    <span className={"cmty-av" + (actor && actor.is_public ? "" : " anon") + rc} style={style} aria-hidden="true" title={title}>
      {cmtyInitials(actor)}
    </span>
  );
}

// ---- photos and videos ------------------------------------------------------
// Uploads live in the public bucket community-media under <user id>/<random>
// (supabase/migrations/community_posts.sql). They are moderated as comments
// are (Ric, 13 Oct 2026): live at once, the author deletes, anyone reports, a
// moderator removes. Photos are made smaller here before they go: a phone's
// 12-megapixel original is 4 MB to send and to read.
const CMTY_MEDIA_BUCKET = "community-media";
const CMTY_VIDEO_MAX_S = 60;
const CMTY_VIDEO_MAX_BYTES = 50 * 1024 * 1024;
function cmtyMediaUrl(path) {
  if (!path || !window.BBR_supabase || !window.BBR_supabase.storage) return null;
  try {
    const out = window.BBR_supabase.storage.from(CMTY_MEDIA_BUCKET).getPublicUrl(path);
    return (out && out.data && out.data.publicUrl) || null;
  } catch (e) { return null; }
}
function cmtyRandName() {
  const a = new Uint8Array(12);
  (window.crypto || window.msCrypto).getRandomValues(a);
  return Array.from(a, (b) => b.toString(16).padStart(2, "0")).join("");
}
// A picture file as a JPEG, its longest side at most `max`, or cut to a centred
// square of side `square`. Phones store a photo's rotation separately;
// createImageBitmap applies it.
function cmtyImageBlob(file, opts) {
  const o = opts || {};
  const load = window.createImageBitmap
    ? window.createImageBitmap(file, { imageOrientation: "from-image" }).catch(() => window.createImageBitmap(file))
    : new Promise((ok, no) => {
      const im = new Image(), u = URL.createObjectURL(file);
      im.onload = () => { URL.revokeObjectURL(u); ok(im); };
      im.onerror = (e) => { URL.revokeObjectURL(u); no(e); };
      im.src = u;
    });
  return Promise.resolve(load).then((img) => {
    const iw = img.width, ih = img.height;
    if (!iw || !ih) throw new Error("unreadable");
    let sx = 0, sy = 0, sw = iw, sh = ih, w, h;
    if (o.square) {
      const s = Math.min(iw, ih);
      sx = (iw - s) / 2; sy = (ih - s) / 2; sw = sh = s;
      w = h = Math.min(o.square, s);
    } else {
      const k = Math.min(1, (o.max || 1600) / Math.max(iw, ih));
      w = Math.round(iw * k); h = Math.round(ih * k);
    }
    const c = document.createElement("canvas");
    c.width = w; c.height = h;
    const g = c.getContext("2d");
    g.fillStyle = "#fff"; g.fillRect(0, 0, w, h);          // a transparent PNG goes white, not black
    g.drawImage(img, sx, sy, sw, sh, 0, 0, w, h);
    if (img.close) try { img.close(); } catch (e) {}
    return new Promise((ok, no) => c.toBlob((b) => (b ? ok({ blob: b, w, h }) : no(new Error("encode"))), "image/jpeg", o.quality || 0.86));
  });
}
const CMTY_VIDEO_TYPES = { "video/mp4": "mp4", "video/quicktime": "mov", "video/webm": "webm" };
function cmtyVideoType(file) {
  if (CMTY_VIDEO_TYPES[file.type]) return file.type;
  const ext = String(file.name || "").toLowerCase().split(".").pop();
  return ext === "mov" ? "video/quicktime" : ext === "webm" ? "video/webm" : ext === "mp4" || ext === "m4v" ? "video/mp4" : null;
}
// A video's length and shape, and a still to show before it plays. A video
// whose length cannot be read is refused: the 60 seconds could not be held.
function cmtyVideoInfo(file) {
  return new Promise((ok, no) => {
    const v = document.createElement("video");
    const url = URL.createObjectURL(file);
    let done = false;
    const end = (fn, x) => { if (done) return; done = true; clearTimeout(t); fn(x); };
    const t = setTimeout(() => { URL.revokeObjectURL(url); end(no, new Error("unreadable")); }, 10000);
    v.muted = true; v.playsInline = true; v.preload = "metadata";
    v.onerror = () => { URL.revokeObjectURL(url); end(no, new Error("unreadable")); };
    v.onloadedmetadata = () => {
      const info = { dur: v.duration, w: v.videoWidth || null, h: v.videoHeight || null, poster: null, url };
      if (!isFinite(info.dur) || info.dur <= 0) { URL.revokeObjectURL(url); end(no, new Error("unreadable")); return; }
      // The still is a nicety: a page in the background never seeks, so it is
      // given two seconds and then the video goes without one.
      const give = setTimeout(() => end(ok, info), 2000);
      v.onseeked = () => {
        try {
          const k = Math.min(1, 1280 / Math.max(v.videoWidth || 1, v.videoHeight || 1));
          const c = document.createElement("canvas");
          c.width = Math.round((v.videoWidth || 0) * k); c.height = Math.round((v.videoHeight || 0) * k);
          if (!c.width || !c.height) { clearTimeout(give); end(ok, info); return; }
          c.getContext("2d").drawImage(v, 0, 0, c.width, c.height);
          c.toBlob((b) => { clearTimeout(give); info.poster = b || null; end(ok, info); }, "image/jpeg", 0.82);
        } catch (e) { clearTimeout(give); end(ok, info); }
      };
      try { v.currentTime = Math.min(0.6, info.dur / 2); } catch (e) { clearTimeout(give); end(ok, info); }
    };
    v.src = url;
  });
}
// WHERE A VIDEO WAS FILMED. Photos lose their metadata when they are redrawn
// here; a video is sent as it is, and phones write the place it was filmed
// into an MP4 or QuickTime file (an ISO 6709 string such as
// "+51.5074-000.1278/", in the ©xyz box or Apple's location key). The bucket
// is public, and an anonymous collector filming at home would be giving their
// address away (review, 13 Oct 2026). Every such string inside the moov box
// is overwritten with zeros of the same length, so nothing else in the file
// moves. A file whose boxes cannot be read goes unchanged.
// Looser than the standard (one or two degree digits, any decimals, the slash
// optional) for writers that do not pad (QA, 14 Oct 2026).
const CMTY_ISO6709 = /[+-]\d{1,2}\.\d+[+-]\d{1,3}\.\d+(?:[+-]\d+(?:\.\d+)?)?(?:CRS[A-Za-z0-9:]*)?\/?/g;
// And XMP's form, from editing apps (exif:GPSLatitude="51,30.444N").
const CMTY_XMP_GPS = /GPS(?:Latitude|Longitude)(?:="|>)\d{1,3},\d{1,2}(?:\.\d+)?[NSEW]/g;
function cmtyStripVideoLocation(file) {
  if (!/^video\/(mp4|quicktime)$/.test(cmtyVideoType(file) || "")) return Promise.resolve(file);
  const read = (a, b) => file.slice(a, b).arrayBuffer();
  // Every top-level box but the media data itself (moov, and a udta, meta or
  // uuid box some writers put beside it; QA, 14 Oct 2026).
  const boxes = async () => {
    const out = [];
    let at = 0;
    while (at + 8 <= file.size && out.length < 100000) {
      const h = new DataView(await read(at, Math.min(at + 16, file.size)));
      let size = h.getUint32(0);
      const t = String.fromCharCode(h.getUint8(4), h.getUint8(5), h.getUint8(6), h.getUint8(7));
      if (size === 1 && h.byteLength >= 16) size = h.getUint32(8) * 4294967296 + h.getUint32(12);
      else if (size === 0) size = file.size - at;
      if (size < 8) break;
      out.push({ at, size: Math.min(size, file.size - at), t });
      at += size;
    }
    return out;
  };
  return boxes().then(async (list) => {
    const parts = [];
    let from = 0, hit = 0;
    for (const b of list) {
      if (b.t === "mdat" || b.size > 64 * 1024 * 1024) continue;
      const bytes = new Uint8Array(await read(b.at, b.at + b.size));
      let text = "";
      for (let i = 0; i < bytes.length; i += 8192) text += String.fromCharCode.apply(null, bytes.subarray(i, i + 8192));
      let m, n = 0;
      for (const re of [CMTY_ISO6709, CMTY_XMP_GPS]) {
        re.lastIndex = 0;
        while ((m = re.exec(text))) {
          for (let i = 0; i < m[0].length; i++) { const c = m[0][i]; if (c >= "1" && c <= "9") bytes[m.index + i] = 48; }
          n++;
        }
      }
      if (!n) continue;
      hit += n;
      parts.push(file.slice(from, b.at), bytes);
      from = b.at + b.size;
    }
    if (!hit) return file;
    parts.push(file.slice(from));
    return new Blob(parts, { type: file.type || cmtyVideoType(file) });
  }).catch(() => file);
}
window.__cmtyStripVideoLocation = cmtyStripVideoLocation;   // for tests
// `path` is chosen once per file by the caller and kept: a retry after a
// timeout sends the same name, and finds it there if the first attempt got
// through after all ("already exists" is success), instead of uploading the
// bytes again under a new name against the day's allowance (QA, 14 Oct 2026).
function cmtyUploadPath(uid, ext) { return uid + "/" + cmtyRandName() + "." + ext; }
function cmtyUpload(path, blob, type) {
  // A stalled upload must not hold the page, but a big file on a slow line is
  // not stalled: a minute, and a second more for every 150 KB.
  const ms = 60000 + Math.ceil(((blob && blob.size) || 0) / 153600) * 1000;
  let timer;
  const late = new Promise((ok, no) => { timer = setTimeout(() => no(new Error("timeout")), ms); });
  return Promise.race([Promise.resolve(window.BBR_supabase.storage.from(CMTY_MEDIA_BUCKET)
    .upload(path, blob, { contentType: type, upsert: false, cacheControl: "31536000" })), late])
    .then((out) => {
      clearTimeout(timer);
      const e = out && out.error;
      if (e && (String(e.statusCode || e.status || "") === "409" || /already exists|duplicate/i.test(String(e.message || "")))) return path;
      if (!out || e) throw e || new Error("upload");
      return path;
    }, (e) => { clearTimeout(timer); throw e; });
}

// A dialog over the page: the body stays put, Back and Escape close it, focus
// comes in and goes back where it was, and Tab stays inside. (As the picture
// chooser does; that one predates this and keeps its own copy.)
function useCmtyDialog(boxRef, onClose, tag) {
  const onCloseRef = useCmRef(onClose); onCloseRef.current = onClose;
  // GOING SOMEWHERE FROM INSIDE. Closing steps back over the dialog's own
  // history entry, and that step lands AFTER the page has pushed its new
  // route, which took the reader straight back (review, 13 Oct 2026). So a
  // link in a dialog steps back first and goes on once the step has landed.
  const leaving = useCmRef(null);
  const key = tag || "bbrCmtyDialog";
  const leave = (fn) => {
    if (history.state && history.state[key]) { leaving.current = fn; history.back(); }
    else { onCloseRef.current(); setTimeout(fn, 0); }
  };
  useCmEffect(() => {
    const y = window.scrollY || 0, b = document.body;
    const prev = { position: b.style.position, top: b.style.top, left: b.style.left, right: b.style.right, width: b.style.width, overflow: b.style.overflow };
    Object.assign(b.style, { position: "fixed", top: "-" + y + "px", left: "0", right: "0", width: "100%", overflow: "hidden" });
    // The page's own history entry stops restoring scroll while the dialog is
    // open. Stepping back over the dialog made the browser put the page's old
    // position back late, onto whatever page a link in the dialog had opened
    // (a shelf opened 2,600 pixels down). The position is put back here.
    let srWas = null;
    try { srWas = history.scrollRestoration; history.scrollRestoration = "manual"; } catch (e) {}
    history.pushState(Object.assign({}, history.state || {}, { [key]: true }), "", location.pathname + location.search + location.hash);
    let popped = false, go = null;
    const onPop = () => {
      popped = true; go = leaving.current; leaving.current = null;
      // Back on the page's own entry: an ordinary close hands restoring back.
      if (!go && srWas) { try { history.scrollRestoration = srWas; } catch (e) {} }
      onCloseRef.current();
    };
    window.addEventListener("popstate", onPop);
    const was = document.activeElement, box = boxRef.current;
    if (box) box.focus({ preventScroll: true });
    const onKey = (e) => {
      if (e.key === "Escape" && !e.defaultPrevented) { onCloseRef.current(); return; }
      if (e.key !== "Tab" || !box) return;
      const f = box.querySelectorAll("button:not([disabled]), input, textarea, a[href], video");
      if (!f.length) return;
      const first = f[0], last = f[f.length - 1], at = document.activeElement;
      if (!box.contains(at)) { e.preventDefault(); first.focus(); }
      else if (e.shiftKey && (at === first || at === box)) { e.preventDefault(); last.focus(); }
      else if (!e.shiftKey && at === last) { e.preventDefault(); first.focus(); }
    };
    document.addEventListener("keydown", onKey);
    return () => {
      window.removeEventListener("popstate", onPop);
      document.removeEventListener("keydown", onKey);
      Object.assign(b.style, prev); window.scrollTo(0, y);
      // Step back over our own entry only (see the picture chooser), and give
      // the browser its scroll restoration back once that step has landed.
      if (!popped && history.state && history.state[key]) {
        const back = () => { window.removeEventListener("popstate", back); try { if (srWas) history.scrollRestoration = srWas; } catch (e) {} };
        window.addEventListener("popstate", back);
        history.back();
      }
      if (was && was.focus && document.contains(was)) { try { was.focus({ preventScroll: true }); } catch (e) {} }
      // A link followed from inside goes on only now, after the page under the
      // dialog has had its scroll position back (or the new page would get it).
      if (go) setTimeout(go, 0);
    };
  }, []);
  return leave;
}

// A record from the feed, opened: the cover large, what it is, what it is
// worth, whose shelf it is on, and the ways on from it. The adds used to be a
// mosaic of small covers that could not be opened (Ric, 13 Oct 2026).
function CmtyRecordSheet({ rec, actor, meId, onClose, onViewCollection }) {
  const boxRef = useCmRef(null);
  const leave = useCmtyDialog(boxRef, onClose, "bbrCmtyRecord");
  const born = useCmRef(Date.now());
  const dive = rec.slug && window.bbrDiveFor ? window.bbrDiveFor(rec.slug) : null;
  const who = actor ? cmtyName(actor, meId) : "";
  const theirs = actor && meId && actor.user_id === meId;
  const portal = window.bbrOverlayPortal || ((n) => n);
  return portal(
    <div className="av2-backdrop cmty-rs-back" onClick={() => { if (Date.now() - born.current > 400) onClose(); }}>
      <div className="av2-modal cmty-rs" role="dialog" aria-modal="true" aria-label={rec.title + " by " + rec.artist} tabIndex={-1} ref={boxRef} onClick={(e) => e.stopPropagation()}>
        <button className="av2-x cmty-rs-x" aria-label="Close" onClick={onClose}>×</button>
        <div className="cmty-rs-cover"><CmtySleeve item={rec} size={260} /></div>
        <div className="cmty-rs-body">
          <h2 className="cmty-rs-t">{rec.title}</h2>
          <div className="cmty-rs-a">{rec.artist}{rec.year ? " · " + rec.year : ""}</div>
          {Number(rec.value) > 0 ? <div className="cmty-rs-v"><b>{cmtyGbp(rec.value)}</b> <span className="cmty-meta">estimated value</span></div> : null}
          {actor ? (
            <div className="cmty-rs-who"><CmtyAvatar actor={actor} size={26} /> <span>On {theirs ? "your" : who + "’s"} shelf</span></div>
          ) : null}
          <div className="cmty-rs-go">
            {actor && onViewCollection ? <button className="cmty-follow on" onClick={() => leave(() => onViewCollection(actor.user_id, ""))}>{theirs ? "Open your shelf" : "View " + who + "’s shelf"}</button> : null}
            {dive && window.__nav ? <button className="cmty-follow" onClick={() => leave(() => window.__nav.goDeepDive(rec.slug))}>Read the deep dive</button> : null}
          </div>
        </div>
      </div>
    </div>
  );
}

// The day's records as a row you can scroll and open, one by one.
function CmtyRecordStrip({ items, more, actor, meId, onOpen, onShelf }) {
  return (
    <ul className="cmty-recs" aria-label="The records">
      {items.map((it, i) => (
        <li key={it.id || i}>
          <button className="cmty-rec" onClick={() => onOpen(it)} aria-label={it.title + " by " + it.artist + ". Open"}>
            <CmtySleeve item={it} size={116} />
            <span className="cmty-rec-t">{it.title}</span>
            <span className="cmty-rec-a">{it.artist}</span>
          </button>
        </li>
      ))}
      {more > 0 ? (
        <li>
          <button className="cmty-rec cmty-rec-more" onClick={onShelf}>
            <span className="cmty-rec-n">+{cmtyNum(more)}</span>
            <span className="cmty-rec-a">more on {actor && meId && actor.user_id === meId ? "your" : "the"} shelf</span>
          </button>
        </li>
      ) : null}
    </ul>
  );
}


// ---- likes and follows ------------------------------------------------------
// Counts and "did I" arrive with the feed and the tiles. A tap is applied at
// once and then settled by what the server says; a refusal puts it back.
// Any signed-in account can like, follow and comment, an empty one included
// (Ric, 13 Oct 2026, to encourage people to come back daily; it needed a
// record on the shelf until then). Signed out opens the sign-up gate.
// `need_record` below is what the server used to answer; it is kept for a
// page still open against an older server answer, and is otherwise unused.
const CMTY_SOCIAL_ERR = {
  need_record: "Add your first record to like and follow.",
  not_yourself: "That one’s yours.",
  slow_down: "That’s a lot for one day. Try again tomorrow.",
  not_allowed: "This account can’t like or follow.",
  no_such_collector: "That shelf has no records on it any more.",
  gone: "That comment isn’t there any more.",
  bad_target: "That post isn’t there any more.",
};
// What the reader has liked and followed this session, kept outside the
// component so it survives opening a shelf and coming back (the page unmounts).
// `over` is what the buttons show; `sure` is the server's last answer per
// target, which is what a failed tap falls back to.
const cmtySocKeep = { meId: null, over: { like: {}, follow: {} }, sure: { like: {}, follow: {} }, subs: new Set() };
// THE TAP THAT OPENED THE GATE. A signed-out visitor taps Like or Follow, the
// sign-up gate opens, they sign up or sign in, and they are back on the page
// ("community" stays in place): the button used to be exactly as they left it,
// unpressed, and had to be tapped again. Remembered here for three minutes and
// carried out once, for whoever signs in on this tab in that time.
const cmtySocWant = { kind: null, key: null, base: null, at: 0 };
function useCmtySocial(meId, authReady, onRequireAuth) {
  const [over, setOverState] = useCmState(cmtySocKeep.meId === (meId || null) ? cmtySocKeep.over : { like: {}, follow: {} });
  const [note, setNote] = useCmState(null);
  // The kept store is written first and the page second. A reply that lands
  // after the reader has left (a refusal, say) used to be lost with the page,
  // because an update to an unmounted component never runs: the feed they went
  // back to kept the optimistic "Following". And not for a different reader.
  const setOver = (fn) => {
    if (typeof fn === "function" && cmtySocKeep.meId !== (meId || null)) return;
    const n = typeof fn === "function" ? fn(cmtySocKeep.over) : fn;
    cmtySocKeep.over = n; setOverState(n);
    // Every other copy on the page too: a shelf page has the header's Follow
    // and one on each post in its Activity, and they disagreed (QA, 14 Oct).
    cmtySocKeep.subs.forEach((f) => f(n));
  };
  useCmEffect(() => {
    const f = (n) => { if (cmtySocKeep.meId === (meId || null)) setOverState(n); };
    cmtySocKeep.subs.add(f);
    return () => { cmtySocKeep.subs.delete(f); };
  }, [meId]);
  // A different reader means different "did I" flags; the refetch brings them.
  // Not before the session has been read: a shelf opened mid-session mounts
  // with no reader yet, and treating that as "signed out" wiped what they had
  // just liked, so the feed they went back to showed it undone. Once auth
  // answers with the same reader, pick the kept flags back up.
  useCmEffect(() => {
    if (authReady === false) return;
    if (cmtySocKeep.meId === (meId || null)) { setOverState(cmtySocKeep.over); return; }
    cmtySocKeep.meId = meId || null;
    cmtySocKeep.sure = { like: {}, follow: {} };
    setOver({ like: {}, follow: {} }); setNote(null);
  }, [meId, authReady]);
  // The refusal line clears itself; it is a toast now, not a paragraph in the page.
  useCmEffect(() => {
    if (!note) return;
    const t = setTimeout(() => setNote(null), 8000);
    return () => clearTimeout(t);
  }, [note]);
  const put = (kind, k, v) => setOver((o) => ({ ...o, [kind]: { ...o[kind], [k]: v } }));
  const drop = (kind, k) => setOver((o) => { const m = { ...o[kind] }; delete m[k]; return { ...o, [kind]: m }; });
  // TAPS RACE. Like then unlike in quick succession sends two requests; if the
  // first answer lands last it used to repaint the button "liked" over a server
  // that had ended on "not liked". Only the newest tap for a target may settle
  // its button. And a REFUSED tap goes back to what the server last confirmed,
  // not to what the button showed when it was tapped: two quick taps by a member
  // with no records were both refused, and the second "rollback" restored the
  // first tap's optimistic "liked" (QA, 11-12 Oct 2026).
  const seq = useCmRef({});
  const next = (k) => (seq.current[k] = (seq.current[k] || 0) + 1);
  const latest = (k, n) => seq.current[k] === n;
  const settle = (kind, k, v) => { cmtySocKeep.sure[kind][k] = v; put(kind, k, v); };
  const undo = (kind, k) => { const v = cmtySocKeep.sure[kind][k]; if (v) put(kind, k, v); else drop(kind, k); };
  const fail = (e) => {
    const code = String((e && e.message) || "");
    setNote({ code, text: CMTY_SOCIAL_ERR[code] || "That didn’t save. Try again in a moment." });
  };
  const call = (fn, args) => Promise.resolve(window.BBR_supabase.rpc(fn, args)).then((out) => {
    if (!out || out.error || !out.data) throw (out && out.error) || new Error("no data");
    return out.data;
  });
  // Signed out opens the gate, but NOT while the session is still being read: a
  // member who tapped in that first moment was asked to create the account they
  // already have. Until auth has answered, a tap does nothing.
  const gate = () => { if (authReady === false) return; onRequireAuth && onRequireAuth("community"); };
  const want = (kind, key, base) => { cmtySocWant.kind = kind; cmtySocWant.key = key; cmtySocWant.base = base || null; cmtySocWant.at = Date.now(); gate(); };
  const acts = useCmRef(null);
  useCmEffect(() => {
    if (!meId || !cmtySocWant.kind) return;
    const w = { kind: cmtySocWant.kind, key: cmtySocWant.key, base: cmtySocWant.base, at: cmtySocWant.at };
    cmtySocWant.kind = null;
    if (Date.now() - w.at > 3 * 60 * 1000 || !acts.current) return;
    // Only ever turns something ON: the visitor asked to like or to follow.
    if (w.kind === "like") acts.current.toggleLike(w.key, Object.assign({}, w.base || {}, { liked: false }));
    else if (w.kind === "follow" && w.key !== meId) acts.current.toggleFollow(w.key, Object.assign({}, w.base || {}, { following: false }));
  }, [meId || null]);
  // What a button shows. The page's own row (`base`) is the fresher count once
  // it agrees with the reader's last action; until then the action wins.
  const likeOf = (key, base) => {
    const b = { liked: !!(base && base.liked), likes: Number(base && base.likes) || 0 };
    const o = over.like[key];
    return o && o.liked !== b.liked ? o : b;
  };
  const followOf = (uid, base) => {
    const b = { following: !!(base && base.following), followers: Number(base && base.followers) || 0 };
    const o = over.follow[uid];
    return o && o.following !== b.following ? o : b;
  };
  const api = {
    meId, note, clearNote: () => setNote(null), likeOf, followOf, gate,
    toggleLike(key, base) {
      if (!meId) { want("like", key, base); return; }
      const cur = likeOf(key, base), on = !cur.liked;
      put("like", key, { liked: on, likes: Math.max(0, cur.likes + (on ? 1 : -1)) });
      setNote(null);
      window.BBR_track && window.BBR_track("community_like", { on: on ? 1 : 0, kind: key.split(":")[0] });
      const n = next("l:" + key);
      call("community_like", { p_target: key, p_on: on }).then(
        (d) => { if (latest("l:" + key, n)) settle("like", key, { liked: !!d.liked, likes: Number(d.likes) || 0 }); },
        (e) => { if (latest("l:" + key, n)) { undo("like", key); fail(e); } });
    },
    toggleFollow(uid, base) {
      if (!meId) { want("follow", uid, base); return; }
      const cur = followOf(uid, base), on = !cur.following;
      put("follow", uid, { following: on, followers: Math.max(0, cur.followers + (on ? 1 : -1)) });
      setNote(null);
      window.BBR_track && window.BBR_track("community_follow", { on: on ? 1 : 0 });
      const n = next("f:" + uid);
      call("community_follow", { p_user: uid, p_on: on }).then(
        (d) => { if (latest("f:" + uid, n)) settle("follow", uid, { following: !!d.following, followers: Number(d.followers) || 0 }); },
        (e) => { if (latest("f:" + uid, n)) { undo("follow", uid); fail(e); } });
    },
  };
  acts.current = api;
  return api;
}
// `mine` is the reader's own post or shelf: the count shows, the button doesn't.
// `what` names the thing for a screen reader ("Maya’s new records"): thirty
// buttons all called "Like" told nobody which was which, and flipping the name
// to "Unlike" as well as aria-pressed read as "Unlike, pressed".
function CmtyLike({ soc, target, base, mine, what, small }) {
  const st = soc.likeOf(target, base);
  if (mine) {
    // Your own: not a button to like with, so a tap shows who liked it.
    return st.likes ? (
      <CmtyLikers target={target} count={st.likes} meId={soc.meId} tap>
        <button type="button" className={"cmty-like mine" + (small ? " small" : "")} aria-label={"Who liked " + (what || "this") + ", " + cmtyPlural(st.likes, "like")}>♡ {cmtyNum(st.likes)}<span className="cmty-vh"> {st.likes === 1 ? "like" : "likes"}</span></button>
      </CmtyLikers>
    ) : null;
  }
  return (
    <CmtyLikers target={target} count={st.likes} meId={soc.meId}>
      <button className={"cmty-like" + (st.liked ? " on" : "") + (small ? " small" : "")} aria-pressed={st.liked}
        aria-label={"Like " + (what || "this") + (st.likes ? ", " + cmtyPlural(st.likes, "like") : "")}
        onClick={() => soc.toggleLike(target, base)}>
        <span aria-hidden="true">{st.liked ? "♥" : "♡"}</span> {st.likes ? cmtyNum(st.likes) : "Like"}
      </button>
    </CmtyLikers>
  );
}
// On a collector's own page, the collector whose page it is: their Activity's
// cards carried a Follow (and links) for the person the header already offers
// (UX review, 14 Oct 2026).
const CmtyOnShelf = React.createContext(null);
function CmtyFollow({ soc, user }) {
  const here = React.useContext(CmtyOnShelf);
  if (!user || (soc.meId && user.user_id === soc.meId) || (here && user.user_id === here)) return null;
  const st = soc.followOf(user.user_id, user);
  return (
    <button className={"cmty-follow" + (st.following ? " on" : "")} aria-pressed={st.following}
      aria-label={(st.following ? "Following " : "Follow ") + cmtyName(user, null) + (st.following ? ". Unfollow" : "")}
      onClick={() => soc.toggleFollow(user.user_id, user)}>{st.following ? "Following" : "Follow"}</button>
  );
}
// One toast for the page, fixed to the bottom of the screen. It used to be a
// paragraph above the first post: a refusal twenty posts down showed a heart
// fill and empty with the reason several screens out of sight (QA, 12 Oct 2026).
function CmtySocialNote({ soc }) {
  if (!soc.note) return null;
  return (
    <p className="cmty-note" role="status">
      <span>{soc.note.text}</span>
      {soc.note.code === "need_record" && window.__nav ? <button className="cmty-link" onClick={() => window.__nav.goCollection()}>Add a record</button> : null}
      <button className="cmty-note-x" aria-label="Dismiss" onClick={soc.clearNote}>&times;</button>
    </p>
  );
}


// ---- comments -----------------------------------------------------------------
// Every post in the feed can be talked about (12 Oct 2026, Ric): a collector's
// new records, the market moves, a new deep dive. Comments post live. The
// author can delete their own, the owner of a post can remove comments on it, a
// moderator can remove any, and anyone signed in can report one (it then stops
// being shown to them). Text only, 500 characters.
//
// The feed row carries the count and the last two comments (community_talk()),
// so a conversation is visible without opening it; opening reads the thread
// (community_comments()). Any signed-in account can post, as it can like
// and follow: no record on the shelf is needed (Ric, 13 Oct 2026).
// Nothing here names anyone: the server decides who a
// commenter is to this reader, and a comment written under a collector's other
// identity is simply not sent (supabase/migrations/community_comments.sql).
const CMTY_TALK_MAX = 500;
const CMTY_TALK_ERR = {
  gone: "That comment isn’t there any more.",
  sign_in: "Sign in to comment.",
  need_record: "Add your first record to join in.",
  not_allowed: "This account can’t comment.",
  slow_down: "That’s a lot in a short while. Try again in a bit.",
  empty: "Write something first.",
  too_long: "That’s over 500 characters.",
  bad_target: "That post isn’t there any more.",
  no_such_collector: "That post isn’t there any more.",
  not_yourself: "That one’s yours.",
};
function cmtyTalkErr(e) {
  const code = String((e && e.message) || "");
  return { code, text: CMTY_TALK_ERR[code] || "That didn’t go through. Try again in a moment." };
}
function CmtyBubble() {
  return (
    <svg className="cmty-bubble" viewBox="0 0 20 20" width="15" height="15" aria-hidden="true" focusable="false">
      <path d="M3.5 4.5h13v9h-7l-3.5 3v-3h-2.5z" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinejoin="round" />
    </svg>
  );
}
// "12 min ago" and "3 hours ago" today; the date after that. (cmtyWhen gives a
// bare clock time for older days, which a post's day heading completes and a
// comment has nothing to complete it.)
function cmtyTalkWhen(iso) {
  const d = new Date(iso);
  if (isNaN(d)) return "";
  if (Date.now() - d.getTime() < 86400000) return cmtyWhen(iso);
  return d.toLocaleDateString("en-GB", d.getFullYear() === new Date().getFullYear() ? { day: "numeric", month: "short" } : { day: "numeric", month: "short", year: "numeric" });
}
// A comment can be liked and answered (Ric, 13 Oct 2026). Replies are one level
// deep and sit under the comment they answer; a reply to a reply joins the
// same comment (the server flattens it). `replies` is that list, drawn here.
// ---- 14 Oct 2026 (Ric): tags, who liked it, the ring explained -------------------

// A post's time where no day heading gives its date (a collector's Activity).
const CmtyDated = React.createContext(false);
function cmtyPostWhen(iso, dated) {
  const d = new Date(iso);
  if (!dated || isNaN(d) || Date.now() - d.getTime() < 86400000) return cmtyWhen(iso);
  const day = d.toLocaleDateString("en-GB", d.getFullYear() === new Date().getFullYear() ? { day: "numeric", month: "short" } : { day: "numeric", month: "short", year: "numeric" });
  return day + ", " + d.toLocaleTimeString("en-GB", { hour: "2-digit", minute: "2-digit" });
}

// TAGGING. "@" in a post or a comment offers collectors to tag, by the name the
// page shows them under (an anonymous collector by alias only). Choosing one
// writes "@name" and keeps their id, sent with the text; the server keeps a tag
// only if "@name" is still there (community_mentions_build) and tells them in
// their Community inbox.
// Reworked the same day (Ric, 14 Oct 2026: "jarring"): the list opens at the
// cursor as soon as "@" is typed (in a comment, with the people already in the
// conversation), answers from what it has while it asks for more, says when it
// is searching or found nobody, a chosen tag is highlighted in the box, and
// Backspace takes a whole tag out.
let cmtyTagSeq = 0;
const cmtyTagFound = new Map();      // what the server answered for a query, kept a minute
const CMTY_TA_COPY = ["boxSizing", "borderTopWidth", "borderRightWidth", "borderBottomWidth", "borderLeftWidth",
  "borderTopStyle", "borderRightStyle", "borderBottomStyle", "borderLeftStyle", "borderRadius",
  "paddingTop", "paddingRight", "paddingBottom", "paddingLeft", "fontFamily", "fontSize", "fontWeight", "fontStyle",
  "lineHeight", "letterSpacing", "wordSpacing", "textTransform", "textIndent", "tabSize", "textAlign", "scrollbarGutter"];
function cmtyTagFits(who, q) {
  if (!q) return true;
  const n = String(who.name || "").toLowerCase(), al = String(who.anon_alias || "").toLowerCase();
  return n.indexOf(q) === 0 || n.split(/[\s_.-]+/).some((w) => w.indexOf(q) === 0) || (!!al && al.indexOf(q) === 0);
}
// "@name" as a whole word: not inside an email address or a longer name
// ("@louise" in "@louise-abell" tagged, and told, louise; QA, 14 Oct 2026).
// The server's community_tag_hit is the same test.
function cmtyTagEsc(name) { return String(name).replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); }
function cmtyTagFind(src, name, from) {          // index of the "@", or -1
  const re = new RegExp("(^|[^\\p{L}\\p{N}_.@-])@" + cmtyTagEsc(name) + "(?=$|[^\\p{L}\\p{N}_-])", "giu");
  // One back, so the character before the "@" can be seen as its boundary.
  re.lastIndex = Math.max(0, (from || 0) - 1);
  const m = re.exec(String(src || ""));
  return m ? m.index + m[1].length : -1;
}
function cmtyTagEnds(src, name) {                // does `src` end with that tag?
  return new RegExp("(^|[^\\p{L}\\p{N}_.@-])@" + cmtyTagEsc(name) + "$", "iu").test(String(src || ""));
}
// The name a tag is written with: what the page shows (none for a collector
// shown only as "Collector", who cannot be told apart).
function cmtyTagName(a) {
  if (!a || !a.user_id) return null;
  if (a.is_public) return a.display_name || null;
  return a.anon_alias ? "Anonymous " + a.anon_alias : null;
}
function useCmtyTag(taRef, text, setText, people, meId) {
  const [q, setQ] = useCmState(null);                     // { at, s } while a tag is being typed
  const [found, setFound] = useCmState({ s: null, list: [] });
  const [busy, setBusy] = useCmState(false);
  const [hi, setHi] = useCmState(0);
  const [pos, setPos] = useCmState(null);                 // where the list goes, in the box's frame
  const picked = useCmRef({});                            // name -> user_id
  const known = useCmRef([]);                             // names the server has offered
  const shut = useCmRef(-1);                              // the "@" the reader closed the list on
  const wrapRef = useCmRef(null), mirRef = useCmRef(null), req = useCmRef(0), idRef = useCmRef(null);
  if (!idRef.current) idRef.current = "cmty-tag-" + (++cmtyTagSeq);

  // The highlight layer sits under the box with the box's own lettering, so a
  // chosen tag shows as a tag while it is being written. Set up for each box
  // as it appears (the post box's only exists while it is open).
  const lastMir = useCmRef(null);
  React.useLayoutEffect(() => {
    const ta = taRef.current, mir = mirRef.current;
    // A box closed and opened again is a new layer: the old one's observer goes.
    if (lastMir.current && lastMir.current !== mir && lastMir.current.__ro) { lastMir.current.__ro.disconnect(); lastMir.current.__ro = null; }
    lastMir.current = mir;
    // A box marked to grow fits its text, up to six lines: a one-line comment
    // box wrapped onto a second line it did not show, tag and all.
    if (ta && ta.dataset.grow) {
      const c = getComputedStyle(ta), n = (k) => parseFloat(c[k]) || 0;
      const pad = n("paddingTop") + n("paddingBottom"), bord = n("borderTopWidth") + n("borderBottomWidth");
      const lh = n("lineHeight") || n("fontSize") * 1.4 || 22;
      ta.style.height = "auto";
      // scrollHeight is the text and its padding; the height set is the
      // box's own kind (border-box counts the border too, content-box neither).
      const want = Math.min(ta.scrollHeight, lh * 6 + pad);
      ta.style.height = (c.boxSizing === "border-box" ? want + bord : want - pad) + "px";
    }
    if (!ta || !mir || mir.__ta === ta) return;
    if (mir.__ro) mir.__ro.disconnect();
    mir.__ta = ta;
    mir.style.backgroundColor = getComputedStyle(ta).backgroundColor;
    ta.style.background = "transparent";
    const fit = () => {
      const c = getComputedStyle(ta);
      CMTY_TA_COPY.forEach((k) => { mir.style[k] = c[k]; });
      mir.style.borderColor = "transparent";
      mir.style.top = ta.offsetTop + "px"; mir.style.left = ta.offsetLeft + "px";
      mir.style.width = ta.offsetWidth + "px"; mir.style.height = ta.offsetHeight + "px";
      mir.scrollTop = ta.scrollTop;
    };
    fit();
    mir.__ro = window.ResizeObserver ? new ResizeObserver(fit) : null;
    if (mir.__ro) mir.__ro.observe(ta);
  });
  useCmEffect(() => () => { const mir = mirRef.current; if (mir && mir.__ro) mir.__ro.disconnect(); }, []);

  // Where the cursor is, measured in a copy of the box.
  const caret = (ta, at) => {
    const wrap = wrapRef.current;
    if (!wrap) return null;
    const c = getComputedStyle(ta), m = document.createElement("div");
    CMTY_TA_COPY.forEach((k) => { m.style[k] = c[k]; });
    Object.assign(m.style, { position: "absolute", visibility: "hidden", top: "0", left: "0", width: ta.offsetWidth + "px",
      whiteSpace: "pre-wrap", overflowWrap: "break-word", wordWrap: "break-word", overflow: "hidden" });
    m.textContent = ta.value.slice(0, at);
    const mark = document.createElement("span");
    mark.textContent = "@";
    m.appendChild(mark);
    wrap.appendChild(m);
    const lh = parseFloat(c.lineHeight) || parseFloat(c.fontSize) * 1.4 || 20;
    const out = { x: ta.offsetLeft + mark.offsetLeft, y: ta.offsetTop + mark.offsetTop - ta.scrollTop, lh };
    wrap.removeChild(m);
    return out;
  };
  const place = (ta, at) => {
    const c = caret(ta, at), wrap = wrapRef.current;
    if (!c || !wrap) return;
    const w = Math.min(300, wrap.clientWidth);
    const left = Math.max(0, Math.min(c.x - 10, wrap.clientWidth - w));
    // Under the line being written, or over it when the screen has no room below.
    // The visible bottom, above an on-screen keyboard (innerHeight does not shrink for one).
    const vv = window.visualViewport, bottom = vv ? vv.offsetTop + vv.height : window.innerHeight;
    const below = wrap.getBoundingClientRect().top + c.y + c.lh + 270 < bottom;
    setPos(below ? { left, top: c.y + c.lh + 6, width: w } : { left, bottom: wrap.clientHeight - c.y + 6, width: w });
  };
  const look = (value, at) => {
    const ta = taRef.current;
    const m = /(^|[\s(])@([^\s@][^@\n]{0,29})?$/.exec(String(value || "").slice(0, at || 0));
    if (!m || (m[2] || "").split(" ").length > 3) { setQ(null); shut.current = -1; return; }
    const t = (m[2] || "").replace(/\s/g, " "), from = at - t.length - 1, tl = t.toLowerCase();
    if (from === shut.current) return;                    // closed with Escape: stays closed for this tag
    // The words after a chosen tag are not a new tag: the list came back after
    // a pick and followed the next three words, "No collector called
    // 'john is'" (QA, 14 Oct 2026).
    if (Object.keys(picked.current).some((n) => tl.indexOf(n.toLowerCase() + " ") === 0)) { setQ(null); return; }
    // A space keeps it open only while a name still starts with what was typed ("Anonymous Pa…").
    if (/ /.test(tl) && !(people || []).concat(known.current).some((p) => p && p.name && p.name.toLowerCase().indexOf(tl) === 0)) { setQ(null); return; }
    setQ((old) => (old && old.at === from && old.s === t ? old : { at: from, s: t }));
    if (ta) place(ta, from);
  };

  // Placed again once the box has grown for this keystroke (it was a line high).
  React.useLayoutEffect(() => { if (q && taRef.current) place(taRef.current, q.at); }, [text]);

  // Asked as the reader types; what is already known shows meanwhile, so the
  // list does not empty and refill on every letter.
  const s0 = q ? q.s : null;
  useCmEffect(() => {
    // Nothing typed yet: no answers left over from an earlier search.
    if (!s0 || !window.BBR_supabase) { setBusy(false); setFound({ s: null, list: [] }); return undefined; }
    // Kept per reader: an answer for one account offered another its own name.
    const key = (meId || "") + "|" + s0.toLowerCase(), got = cmtyTagFound.get(key);
    if (got && Date.now() - got.at < 60000) { known.current = got.list; setFound({ s: s0, list: got.list }); setBusy(false); return undefined; }
    const id = ++req.current;
    setBusy(true);
    const t = setTimeout(() => {
      Promise.resolve(window.BBR_supabase.rpc("community_mention_search", { p_q: s0 })).then((out) => {
        if (id !== req.current) return;
        const list = out && !out.error && Array.isArray(out.data) ? out.data : [];
        cmtyTagFound.set(key, { at: Date.now(), list });
        known.current = list;
        setFound({ s: s0, list }); setBusy(false);
      }, () => { if (id === req.current) { setFound({ s: s0, list: [] }); setBusy(false); } });
    }, 120);
    return () => clearTimeout(t);
  }, [s0, meId]);

  const want = q ? q.s.toLowerCase() : "";
  const local = (people || []).filter((p) => p && p.name && p.user_id && cmtyTagFits(p, want));
  const seen = {};
  const list = local.concat((found.list || []).filter((p) => cmtyTagFits(p, want))).filter((p) => {
    if (seen[p.user_id]) return false; seen[p.user_id] = true; return true;
  }).slice(0, 8);
  useCmEffect(() => { setHi(0); }, [want, list.length]);
  const open = !!q;
  const note = !open ? "" : list.length ? "" : !want ? "Type a name to tag a collector"
    : busy || found.s !== q.s ? "Searching…" : "No collector called “" + q.s + "”";

  const pick = (who) => {
    const ta = taRef.current;
    if (!q || !ta || !who) return;
    // The rest of the word under the cursor goes too ("@jo|hx" is one tag),
    // and a space is added only if one does not follow already.
    const at = q.at;
    let end = ta.selectionStart;
    while (end < text.length && !/\s/.test(text[end])) end++;
    const rest = text.slice(end), ins = "@" + who.name + (rest[0] === " " ? "" : " ");
    const caretAt = at + ins.length + (rest[0] === " " ? 1 : 0);
    picked.current[who.name] = who.user_id;
    setText(text.slice(0, at) + ins + rest);
    setQ(null);
    setTimeout(() => { ta.focus(); try { ta.setSelectionRange(caretAt, caretAt); } catch (e) {} }, 0);
  };
  // True when the key was the tag's (the caller does nothing more with it).
  const onKey = (e) => {
    const ta = e.target;
    if (e.key === "Backspace" && ta.selectionStart === ta.selectionEnd) {
      // Straight after a tag, Backspace takes the whole tag.
      const at = ta.selectionStart, before = ta.value.slice(0, at);
      // Straight after a pick the cursor is past the space added with it, so
      // "@name " counts as straight after the tag too.
      let name = Object.keys(picked.current).find((n) => cmtyTagEnds(before, n)), sp = 0;
      if (!name && / $/.test(before)) { name = Object.keys(picked.current).find((n) => cmtyTagEnds(before.slice(0, -1), n)); sp = name ? 1 : 0; }
      if (name) {
        e.preventDefault();
        const from = at - sp - name.length - 1;
        // And the space typed with it, so no double space is left behind.
        const rest = ta.value.slice(at);
        setText(ta.value.slice(0, from) + (!sp && rest[0] === " " && (from === 0 || ta.value[from - 1] === " ") ? rest.slice(1) : rest));
        setTimeout(() => { try { ta.setSelectionRange(from, from); } catch (er) {} }, 0);
        return true;
      }
    }
    if (!open) return false;
    if (e.key === "Escape") { e.preventDefault(); e.stopPropagation(); shut.current = q.at; setQ(null); return true; }
    if (!list.length) return false;
    if (e.key === "ArrowDown") { e.preventDefault(); setHi((h) => (h + 1) % list.length); return true; }
    if (e.key === "ArrowUp") { e.preventDefault(); setHi((h) => (h - 1 + list.length) % list.length); return true; }
    if (e.key === "Enter" || e.key === "Tab") { e.preventDefault(); pick(list[Math.min(hi, list.length - 1)]); return true; }
    return false;
  };
  useCmEffect(() => {                                       // the chosen row stays in view
    if (!open || !list.length) return;
    const el = document.getElementById(idRef.current + "-" + hi);
    if (el && el.scrollIntoView) el.scrollIntoView({ block: "nearest" });
  }, [hi, open, list.length]);

  // Only tags that still stand as written: a removed "@jo" inside a kept
  // "@john" was sent, and told jo (QA, 14 Oct 2026).
  const ids = (value) => Object.keys(picked.current).filter((n) => cmtyTagFind(value, n, 0) >= 0).map((n) => picked.current[n]);
  const reset = () => { picked.current = {}; shut.current = -1; setQ(null); };

  // The text again, with each standing tag marked, for the layer under the box.
  const marked = () => {
    const names = Object.keys(picked.current).sort((a, b) => b.length - a.length);
    const src = String(text || ""), out = [];
    let i = 0, k = 0;
    while (names.length && i < src.length) {
      let at = -1, len = 0;
      names.forEach((n) => { const j = cmtyTagFind(src, n, i); if (j >= 0 && (at < 0 || j < at)) { at = j; len = n.length + 1; } });
      if (at < 0) break;
      if (at > i) out.push(src.slice(i, at));
      out.push(<mark key={k++} className="cmty-tag-hl">{src.slice(at, at + len)}</mark>);
      i = at + len;
    }
    out.push(src.slice(i) + (/\n$/.test(src) ? " " : ""));
    return out;
  };

  const field = (ta) => {
    const p = ta.props;
    return (
      <div className="cmty-tagwrap" ref={wrapRef}>
        <div className="cmty-tag-mirror" ref={mirRef} aria-hidden="true">{marked()}</div>
        {React.cloneElement(ta, {
          "aria-autocomplete": "list",
          "aria-controls": open && list.length ? idRef.current : undefined,
          "aria-activedescendant": open && list.length ? idRef.current + "-" + Math.min(hi, list.length - 1) : undefined,
          onChange: (e) => { if (p.onChange) p.onChange(e); look(e.target.value, e.target.selectionStart); },
          onSelect: (e) => { look(e.target.value, e.target.selectionStart); },
          onKeyDown: (e) => { if (onKey(e)) return; if (p.onKeyDown) p.onKeyDown(e); },
          onScroll: (e) => { if (mirRef.current) mirRef.current.scrollTop = e.target.scrollTop; },
          onBlur: (e) => { setQ(null); if (p.onBlur) p.onBlur(e); },
        })}
        {open && pos ? (
          <div className="cmty-tags" style={pos} onMouseDown={(e) => e.preventDefault()}>
            {list.length ? (
              <React.Fragment>
                {!want && local.length ? <div className="cmty-tags-k" aria-hidden="true">In this conversation</div> : null}
                <ul id={idRef.current} role="listbox" aria-label="Collectors to tag">
                  {list.map((who, i) => (
                    <li key={who.user_id} id={idRef.current + "-" + i} role="option" aria-selected={i === hi} className={i === hi ? "on" : ""}
                      onMouseDown={(e) => { e.preventDefault(); pick(who); }} onMouseMove={() => { if (i !== hi) setHi(i); }}>
                      <CmtyAvatar actor={who} size={28} />
                      <span className="cmty-tags-n">{who.name}</span>
                    </li>
                  ))}
                </ul>
              </React.Fragment>
            ) : <div className="cmty-tags-note">{note}</div>}
          </div>
        ) : null}
        <span className="cmty-vh" aria-live="polite">{open ? (list.length ? cmtyPlural(list.length, "collector") + " to tag. Up and down to choose, Enter to tag." : note) : ""}</span>
      </div>
    );
  };
  return { field, ids, reset };
}

// Text with its tags as links to the tagged collector's shelf. `mentions` are
// the tags the server still stands by (the identity they named is still the
// one shown); anything else stays as it was written.
function CmtyRich({ text, mentions, onOpen, className }) {
  const s = String(text || "");
  const ms = (mentions || []).filter((m) => m && m.name && m.user_id).sort((a, b) => b.name.length - a.name.length);
  if (!ms.length) return <p className={className} dir="auto">{s}</p>;
  const out = [];
  let i = 0, k = 0;
  while (i < s.length) {
    let hit = null, at = -1;
    ms.forEach((m) => { const j = cmtyTagFind(s, m.name, i); if (j >= 0 && (at < 0 || j < at)) { at = j; hit = m; } });
    if (!hit) { out.push(s.slice(i)); break; }
    if (at > i) out.push(s.slice(i, at));
    const len = hit.name.length + 1, who = hit;
    out.push(<button key={k++} type="button" className="cmty-mention" onClick={() => onOpen && onOpen(who.user_id, "")}>{s.slice(at, at + len)}</button>);
    i = at + len;
  }
  return <p className={className} dir="auto">{out}</p>;
}

// WHO LIKED IT, on hover or focus (and a tap on your own count, which is not
// a button to like with). Anyone can see it (Ric, 14 Oct 2026). Kept a minute
// per target and count, so moving across a feed does not ask again.
const cmtyLikersKeep = new Map();
function cmtyLikersRead(target, count) {
  const k = target + "|" + count, got = cmtyLikersKeep.get(k);
  if (got && Date.now() - got.at < 60000) return got.p;
  const p = Promise.resolve(window.BBR_supabase.rpc("community_likers", { p_target: target, p_limit: 12 }))
    .then((out) => (out && !out.error && out.data ? out.data : null), () => null);
  cmtyLikersKeep.set(k, { at: Date.now(), p });
  p.then((d) => { if (!d) cmtyLikersKeep.delete(k); });
  return p;
}
function cmtyLikersLine(d, meId) {
  if (!d) return "Couldn’t load who liked this.";
  const names = (d.likers || []).map((a, i) => { const n = cmtyName(a, meId); return i > 0 && n === "You" ? "you" : n; });
  const total = Math.max(Number(d.total) || 0, names.length);
  if (!names.length) return "Liked by " + cmtyPlural(total, "collector");
  const shown = names.slice(0, 8), rest = total - shown.length;
  if (!rest) return "Liked by " + (shown.length === 1 ? shown[0] : shown.slice(0, -1).join(", ") + " and " + shown[shown.length - 1]);
  return "Liked by " + shown.join(", ") + " and " + (rest === 1 ? "1 other" : rest + " others");
}
let cmtyTipSeq = 0;
// Hover or keyboard focus shows it; on a phone, press and hold (a tap is a
// like). Hidden as soon as the like is tapped, so it never shows the list from
// before the tap. Kept on screen at the edges.
function CmtyLikers({ target, count, meId, tap, children }) {
  const [show, setShow] = useCmState(false);
  const [data, setData] = useCmState(undefined);
  const t = useCmRef(null), hold = useCmRef(null), held = useCmRef(false), live = useCmRef(true);
  const idRef = useCmRef(null), tipRef = useCmRef(null), wrapRef = useCmRef(null);
  if (!idRef.current) idRef.current = "cmty-likers-" + (++cmtyTipSeq);
  useCmEffect(() => () => { live.current = false; clearTimeout(t.current); clearTimeout(hold.current); }, []);
  const read = () => { setData(undefined); cmtyLikersRead(target, count).then((d) => { if (live.current) setData(d); }); };
  const open = () => { if (!count || !window.BBR_supabase) return; setShow(true); read(); };
  const later = () => { clearTimeout(t.current); t.current = setTimeout(open, 300); };
  const hide = () => { clearTimeout(t.current); clearTimeout(hold.current); setShow(false); };
  useCmEffect(() => {
    if (!show) return undefined;
    const esc = (e) => { if (e.key === "Escape") setShow(false); };
    document.addEventListener("keydown", esc);
    return () => document.removeEventListener("keydown", esc);
  }, [show]);
  React.useLayoutEffect(() => {                           // inside the screen
    const tip = tipRef.current, wrap = wrapRef.current;
    if (!show || !tip || !wrap) return;
    tip.style.left = "0px";
    const r = tip.getBoundingClientRect(), vw = document.documentElement.clientWidth;
    if (r.right > vw - 8) tip.style.left = Math.max(8 - wrap.getBoundingClientRect().left, vw - 8 - r.right) + "px";
  }, [show, data]);
  const kid = React.Children.only(children);
  const trigger = React.cloneElement(kid, {
    "aria-describedby": show ? idRef.current : undefined,
    onClick: (e) => {
      if (held.current) { held.current = false; e.preventDefault(); return; }   // a hold, not a tap
      if (tap) { if (!count) return; if (show) { hide(); return; } open(); return; }
      hide();
      if (kid.props.onClick) kid.props.onClick(e);
    },
    onContextMenu: (e) => { if (held.current) e.preventDefault(); },
  });
  return (
    <span className="cmty-likers" ref={wrapRef}
      onPointerEnter={(e) => { if (e.pointerType === "mouse") later(); }}
      onPointerLeave={(e) => { if (e.pointerType === "mouse") hide(); }}
      onPointerDown={(e) => {
        if (e.pointerType === "mouse" || tap) return;
        held.current = false; clearTimeout(hold.current);
        hold.current = setTimeout(() => { held.current = true; open(); }, 450);
      }}
      onPointerUp={() => clearTimeout(hold.current)} onPointerCancel={() => clearTimeout(hold.current)}
      onFocus={(e) => { if (e.target.matches && e.target.matches(":focus-visible")) later(); }} onBlur={hide}>
      {trigger}
      {show ? <span className="cmty-likers-tip" role="tooltip" id={idRef.current} ref={tipRef}>{data === undefined ? "Loading…" : cmtyLikersLine(data, meId)}</span> : null}
    </span>
  );
}

// "Grail collector" / "Rare collector", and what that means (Ric, 14 Oct
// 2026): a tap or a hover says it, with the way to how tiers work when the
// page shows it (a shelf does, under its counts).
const CMTY_RING_NOTE = {
  grail: "Grail collector: owns at least one Grail, a pressing worth about £400 or more in Near Mint that is hard to find. The records people hunt for years.",
  rare: "Rare collector: their rarest records are Rare, pressings worth about £150 or more in Near Mint that are hard to find. One tier below Grail.",
};
// The same chip and the same panel as the collector level beside it (Ric, 14
// Oct 2026: the two worked differently): a tap opens the ladder with theirs
// lit, a tap outside or Escape closes it.
const CMTY_RING_LADDER = [
  { k: "grail", name: "Grail", note: "£400+, hard to find" },
  { k: "rare", name: "Rare", note: "£150+, hard to find" },
];
function CmtyRingPill({ ring }) {
  const [open, setOpen] = useCmState(false);
  const wrap = useCmRef(null);
  // One chip panel at a time (see CollectorBadge).
  useCmEffect(() => {
    const f = (e) => { if (e.detail !== wrap.current) setOpen(false); };
    window.addEventListener("bbr-chip-open", f);
    return () => window.removeEventListener("bbr-chip-open", f);
  }, []);
  useCmEffect(() => {
    if (!open) return undefined;
    try { window.dispatchEvent(new CustomEvent("bbr-chip-open", { detail: wrap.current })); } catch (e) {}
    const off = (e) => { if (wrap.current && !wrap.current.contains(e.target)) setOpen(false); };
    const esc = (e) => { if (e.key === "Escape") setOpen(false); };
    document.addEventListener("click", off); document.addEventListener("keydown", esc);
    return () => { document.removeEventListener("click", off); document.removeEventListener("keydown", esc); };
  }, [open]);
  if (ring !== "grail" && ring !== "rare") return null;
  const label = ring === "grail" ? "Grail collector" : "Rare collector";
  const tiers = document.querySelector("details.tier-how");
  const how = () => {
    const d = document.querySelector("details.tier-how");
    if (!d) return;
    d.open = true; setOpen(false);
    try { d.scrollIntoView({ block: "center", behavior: "smooth" }); } catch (e) { d.scrollIntoView(); }
    const sm = d.querySelector("summary"); if (sm) sm.focus({ preventScroll: true });
  };
  return (
    <span className={"mc-lvl cmty-ringpill" + (open ? " open" : "")} ref={wrap}>
      <button type="button" className={"mc-lvl-chip pubcol-ring " + ring} aria-haspopup="dialog" aria-expanded={open}
        aria-label={label + ". Show what it means."} onClick={(e) => { e.stopPropagation(); setOpen((o) => !o); }}>
        <span className="mc-lvl-dot" aria-hidden="true">●</span>{label}
      </button>
      {open ? (
        <div className="mc-lvl-pop" role="dialog" aria-label={label}>
          <div className="mc-lvl-pop-h">{label}</div>
          <p className="mc-lvl-pop-p">{CMTY_RING_NOTE[ring]}</p>
          <ul className="mc-lvl-pop-list">
            {CMTY_RING_LADDER.map((t) => (
              <li key={t.k} className={t.k === ring ? "on" : ""}>
                <span className="mc-lvl-pop-name">{t.name}</span>
                <span className="mc-lvl-pop-range">{t.note}</span>
              </li>
            ))}
          </ul>
          <div className="mc-lvl-pop-foot">
            {tiers ? <button type="button" className="cmty-link" onClick={how}>How tiers work</button> : <span>Prices are for a Near Mint copy of the pressing.</span>}
          </div>
        </div>
      ) : null}
    </span>
  );
}

// The reader, as the page draws them (picture and ring), for the post box.
const cmtyMeKeep = { id: null, actor: null };
function useCmtyMeActor(meId) {
  const [a, setA] = useCmState(cmtyMeKeep.id === meId ? cmtyMeKeep.actor : null);
  useCmEffect(() => {
    if (!meId || !window.BBR_supabase) { setA(null); return undefined; }
    let live = true;
    const read = (fresh) => {
      if (!fresh && cmtyMeKeep.id === meId && cmtyMeKeep.actor) { setA(cmtyMeKeep.actor); return; }
      Promise.resolve(window.BBR_supabase.rpc("community_person", { p_user: meId })).then((out) => {
        if (!live || !out || out.error || !out.data) return;
        cmtyMeKeep.id = meId; cmtyMeKeep.actor = out.data; setA(out.data);
      }, () => {});
    };
    read(false);
    const f = () => read(true);
    window.addEventListener("bbr-cmty-avatar", f);
    return () => { live = false; window.removeEventListener("bbr-cmty-avatar", f); };
  }, [meId]);
  return a;
}

// Everyone who owns a deep dive's record (the feed says "4 collectors own it").
function CmtyOwnersSheet({ slug, title, meId, onClose, onViewCollection }) {
  const boxRef = useCmRef(null);
  const leave = useCmtyDialog(boxRef, onClose, "bbrCmtyOwners");
  const born = useCmRef(Date.now());
  const [d, setD] = useCmState(null);
  const [err, setErr] = useCmState(false);
  const [tries, setTry] = useCmState(0);
  useCmEffect(() => {
    let live = true;
    Promise.resolve(window.BBR_supabase.rpc("community_dive_owners", { p_slug: slug, p_limit: 100 })).then((out) => {
      if (!live) return;
      if (!out || out.error || !out.data) { setErr(true); return; }
      setD(out.data);
    }, () => { if (live) setErr(true); });
    return () => { live = false; };
  }, [slug, tries]);
  const portal = window.bbrOverlayPortal || ((n) => n);
  const list = (d && d.owners) || [];
  return portal(
    <div className="av2-backdrop cmty-rs-back" onClick={() => { if (Date.now() - born.current > 400) onClose(); }}>
      <div className="av2-modal cmty-rs cmty-owners-sheet" role="dialog" aria-modal="true" aria-labelledby="cmty-owners-h" tabIndex={-1} ref={boxRef} onClick={(e) => e.stopPropagation()}>
        <button className="av2-x cmty-rs-x" aria-label="Close" onClick={onClose}>×</button>
        <div className="cmty-rs-body">
          <h2 className="cmty-rs-t" id="cmty-owners-h">Who owns {title || "it"}</h2>
          {err ? <p className="cmty-meta">That didn’t load. <button type="button" className="cmty-link" onClick={() => { setErr(false); setTry((n) => n + 1); }}>Try again</button></p> : !d ? <p className="cmty-meta">Loading…</p> : (
            <React.Fragment>
              <ul className="cmty-owners-list">
                {list.map((o) => (
                  <li key={o.user_id}>
                    <CmtyAvatar actor={o} size={32} />
                    {onViewCollection ? <button className="cmty-name" onClick={() => leave(() => onViewCollection(o.user_id, ""))}>{cmtyName(o, meId)}</button> : <span>{cmtyName(o, meId)}</span>}
                  </li>
                ))}
              </ul>
              {Number(d.total) > list.length ? <p className="cmty-meta">And {cmtyNum(Number(d.total) - list.length)} more.</p> : null}
            </React.Fragment>
          )}
        </div>
      </div>
    </div>
  );
}

// The second step of Delete / Remove / Report, the same everywhere (UX review,
// 14 Oct 2026): the question, a verb that does it, a way out; Escape is the
// way out, and focus starts on it (a report cannot be taken back).
function CmtyAsk({ q, yes, no, busy, onYes, onNo, noRef }) {
  return (
    <span className="cmty-c-ask" role="group" aria-label={q}
      onKeyDown={(e) => { if (e.key === "Escape") { e.stopPropagation(); onNo(); } }}>
      {q}
      <span className="cmty-c-yns">
        <button type="button" className="cmty-c-yn yes" disabled={busy} onClick={onYes}>{yes}</button>
        <button type="button" className="cmty-c-yn" ref={noRef} disabled={busy} onClick={onNo}>{no}</button>
      </span>
    </span>
  );
}
function CmtyComment({ c, meId, soc, onViewCollection, onRemove, onReport, onReply, replies, hidden, onShowReplies }) {
  const [ask, setAsk] = useCmState(false);        // false | "remove" | "report"
  const [busy, setBusy] = useCmState(false);
  const a = c.actor || {};
  // Focus follows the two-step: to "No" when the question appears (the safe
  // answer), back to the link if it is declined. Without this, pressing Delete
  // swapped the focused button for a span and focus fell to the top of the page.
  const noRef = useCmRef(null), delRef = useCmRef(null), was = useCmRef(false);
  useCmEffect(() => {
    if (ask && noRef.current) noRef.current.focus();
    else if (!ask && was.current && delRef.current) delRef.current.focus();
    was.current = !!ask;
  }, [ask]);
  // Whoever can remove a comment has no use for Report, and the two sat side
  // by side: Report hides the comment from the reporter, which took Remove
  // away from the one person who could have used it.
  const canReport = !!meId && !c.mine && !c.can_remove;
  return (
    <li className="cmty-c" data-cid={c.id} tabIndex={-1}>
      <CmtyAvatar actor={a} size={28} />
      <div className="cmty-c-main">
        <div className="cmty-c-head">
          <button className="cmty-name" onClick={() => onViewCollection && onViewCollection(a.user_id, cmtyName(a, null))}>{cmtyName(a, meId)}</button>
          <span className="cmty-meta">{cmtyTalkWhen(c.at)}</span>
        </div>
        <CmtyRich className="cmty-c-body" text={c.body} mentions={c.mentions} onOpen={onViewCollection} />
        <div className="cmty-c-acts">
          {soc ? <CmtyLike soc={soc} target={"comment:" + c.id} base={c} mine={!!c.mine} what={"this comment by " + cmtyName(a, null)} small /> : null}
          {onReply ? <button className="cmty-link" onClick={() => onReply(c)} aria-label={"Reply to " + cmtyName(a, meId)}>Reply</button> : null}
        {c.can_remove || canReport ? (
          <React.Fragment>
            {/* Two taps for both: the links sit under a thumb on a phone, and a
                report cannot be taken back (it hides the comment from the
                reporter for good and goes to the moderators). */}
            {ask ? (
              <CmtyAsk q={ask === "report" ? "Report this comment to the moderators?" : c.mine ? "Delete your comment?" : "Remove this comment for everyone?"}
                yes={ask === "report" ? "Report" : c.mine ? "Delete" : "Remove"} no={ask !== "report" && c.mine ? "Keep" : "Cancel"}
                busy={busy} noRef={noRef} onNo={() => setAsk(false)}
                onYes={() => { setBusy(true); (ask === "report" ? onReport(c) : onRemove(c)).then(null, () => { setBusy(false); setAsk(false); }); }} />
            ) : (
              <React.Fragment>
                {c.can_remove ? <button className="cmty-link" ref={delRef} onClick={() => setAsk("remove")}>{c.mine ? "Delete" : "Remove"}</button> : null}
                {canReport ? <button className="cmty-link" ref={c.can_remove ? null : delRef} onClick={() => setAsk("report")}>Report</button> : null}
              </React.Fragment>
            )}
          </React.Fragment>
        ) : null}
        </div>
        {replies && replies.length ? (
          <ul className="cmty-cs cmty-replies" aria-label={"Replies to " + cmtyName(a, null)}>{replies}</ul>
        ) : null}
        {hidden > 0 && onShowReplies ? (
          <button className="cmty-link cmty-c-more" onClick={onShowReplies}>View {hidden === 1 ? "1 reply" : cmtyNum(hidden) + " replies"}</button>
        ) : null}
      </div>
    </li>
  );
}
// A reader who was signed out and tapped the sign-in line under a thread: the
// feed is read again as them once they are in, and a post from further down
// than the first page would be gone with its thread. The feed brings this one
// back to the top when the reader changes (CmtyFeed).
const cmtyTalkWant = { key: null, at: 0 };
// One post's conversation. Returns the button for the post's footer and the
// thread to set beneath it, so each kind of post can place them itself.
// onTalk(key, { comments, talk }) tells the feed, which keeps the posts.
// `what` names the post for a screen reader ("Maya's new records").
function useCmtyTalk(ev, meId, soc, onViewCollection, onTalk, startOpen, what) {
  const [open, setOpen] = useCmState(!!startOpen);
  const [rows, setRows] = useCmState(null);       // the thread once read; until then the feed's own two stand in
  const [total, setTotal] = useCmState(Number(ev.comments) || 0);
  const [gone, setGoneState] = useCmState({});    // removed or reported here, by id
  const [text, setText] = useCmState("");
  const [busy, setBusy] = useCmState(false);
  const [older, setOlder] = useCmState(false);
  const [err, setErr] = useCmState(null);
  const [said, setSaid] = useCmState("");
  // The comment being answered, if any: { id, name }. One box for the thread,
  // which says who it is replying to, as most apps do.
  const [replyTo, setReplyTo] = useCmState(null);
  // Comments whose replies have been opened in a closed thread's preview.
  const ta = useCmRef(null), btn = useCmRef(null);
  // The people in this conversation, offered first when "@" is typed.
  const people = useCmMemo(() => {
    const out = [], seen = {};
    [ev.actor].concat((rows !== null ? rows : ev.talk || []).map((c) => c && c.actor)).forEach((a) => {
      const name = cmtyTagName(a);
      if (!name || !a || seen[a.user_id] || (meId && a.user_id === meId)) return;
      seen[a.user_id] = true; out.push(Object.assign({}, a, { name }));
    });
    return out;
  }, [rows, ev.talk, ev.actor, meId]);
  const tag = useCmtyTag(ta, text, setText, people, meId);
  const live = useCmRef(true);
  // Kept beside the state because two removals in a row each read it before
  // the other has landed: built from the state they closed over, the second
  // answer put the first comment back.
  const goneRef = useCmRef({}), rowsRef = useCmRef(null), totalRef = useCmRef(Number(ev.comments) || 0);
  rowsRef.current = rows; totalRef.current = total;
  const seq = useCmRef(0);                         // only the newest read of the thread may write it
  useCmEffect(() => { live.current = true; return () => { live.current = false; }; }, []);
  const call = (fn, args) => Promise.resolve(window.BBR_supabase.rpc(fn, args)).then((out) => {
    if (!out || out.error || !out.data) throw (out && out.error) || new Error("no data");
    return out.data;
  });
  const shown = (list) => (list || []).filter((c) => !goneRef.current[c.id]);
  // The feed keeps a post's last two COMMENTS (not replies), each with how
  // many replies it has, as the server sends them.
  // Before the thread has been read, `list` is the preview plus what was just
  // written, so a new reply adds to the server's count rather than replacing
  // it ("View 6 replies" became "View 1 reply"; QA, 14 Oct 2026).
  const tell = (list, n) => {
    if (!onTalk) return;
    const l = shown(list), kid = {}, partial = rowsRef.current === null;
    l.forEach((c) => { if (c.parent_id) kid[c.parent_id] = (kid[c.parent_id] || 0) + 1; });
    onTalk(ev.key, { comments: n, talk: l.filter((c) => !c.parent_id).slice(-2)
      .map((c) => (kid[c.id] != null ? Object.assign({}, c, { replies: kid[c.id] + (partial ? Number(c.replies) || 0 : 0) }) : c)) });
  };
  // A comment taken away takes its replies with it, here and in the count.
  const withKids = (c) => 1 + (c.parent_id ? 0
    : rowsRef.current !== null ? shown(rowsRef.current).filter((x) => x.parent_id === c.id).length : Number(c.replies) || 0);
  const load = () => {
    const mine = ++seq.current;
    return call("community_comments", { p_target: ev.key, p_limit: 50 }).then((d) => {
      if (!live.current || mine !== seq.current) return;
      setRows(Array.isArray(d.comments) ? d.comments : []);
      setTotal(Number(d.total) || 0);
    }, () => { if (live.current && mine === seq.current) setErr({ code: "load", text: "The comments didn’t load. Try again in a moment." }); });
  };
  // Opened: read the thread. Again when the reader changes, for what is theirs.
  useCmEffect(() => { if (open) load(); }, [open, meId || null]);
  // The post's own count moved (the feed was read again, or the same post is
  // open in a second place and something was said there).
  useCmEffect(() => {
    const n = Number(ev.comments) || 0;
    if (!open) setTotal(n);
    else if (rowsRef.current !== null && n !== totalRef.current) load();
  }, [ev.comments]);
  // Closed, a thread is its last two comments again, not everything it loaded.
  // Replies sit under the comment they answer; closed, they are a count.
  const all = shown(rows !== null ? rows : (ev.talk || []));
  const tops = all.filter((c) => !c.parent_id);
  const kids = {};
  all.forEach((c) => { if (c.parent_id) (kids[c.parent_id] = kids[c.parent_id] || []).push(c); });
  const list = open ? tops : tops.slice(-2);
  const start = (to, quiet) => {
    setReplyTo(to && to.id ? to : null);
    setOpen(true);
    // Reading replies is not writing one: no keyboard over them on a phone.
    if (quiet === true) return;
    setTimeout(() => { if (ta.current) { ta.current.focus(); if (ta.current.scrollIntoView) ta.current.scrollIntoView({ block: "nearest" }); } }, 80);
  };
  const earlier = () => {
    if (older || !rows || !rows.length) return;
    setOlder(true);
    call("community_comments", { p_target: ev.key, p_limit: 50, p_before: rows[0].at }).then((d) => {
      if (!live.current) return;
      setOlder(false);
      const have = {}; (rowsRef.current || []).forEach((c) => { have[c.id] = true; });
      const more = (Array.isArray(d.comments) ? d.comments : []).filter((c) => !have[c.id]);
      setRows(more.concat(rowsRef.current || []));
      // Nothing earlier: the total is what is showing (not counting anything
      // removed or reported here, or the button would never go away).
      if (!more.length) setTotal(shown(rowsRef.current).length);
    }, () => { if (live.current) { setOlder(false); setErr({ code: "older", text: "The earlier comments didn’t load. Try again in a moment." }); } });
  };
  const post = () => {
    const body = text.trim();
    if (!body || busy) return;
    if (!meId) { soc.gate(); return; }
    setBusy(true); setErr(null); setSaid("");
    const parent = replyTo ? replyTo.id : null;
    const args = parent ? { p_target: ev.key, p_body: body, p_parent: parent } : { p_target: ev.key, p_body: body };
    // Tags only when there are some: the call stays the one the server has
    // always answered.
    const tags = tag.ids(body);
    if (tags.length) args.p_mentions = tags;
    call("community_comment_add", args).then((d) => {
      if (!live.current) return;
      setBusy(false); setText(""); setReplyTo(null); tag.reset();
      const n = Number(d.comments) || 0;
      setTotal(n);
      seq.current++;                               // a read still on its way predates this comment
      const base = rowsRef.current !== null ? rowsRef.current : (ev.talk || []);
      const next = d.comment ? base.filter((c) => c.id !== d.comment.id).concat([d.comment]) : base;
      if (rowsRef.current !== null) setRows(next); else load();
      tell(next, n);
      setSaid(parent ? "Reply posted." : "Comment posted.");
      // Back to the box: the button that was pressed is disabled now.
      setTimeout(() => { if (ta.current) ta.current.focus(); }, 0);
      window.BBR_track && window.BBR_track("community_comment", { kind: ev.kind, reply: parent ? 1 : 0 });
    }, (e) => { if (live.current) { setBusy(false); setErr(cmtyTalkErr(e)); } });
  };
  const drop = (c, n, line) => {
    const away = { [c.id]: true };
    (rowsRef.current || []).forEach((x) => { if (x.parent_id === c.id) away[x.id] = true; });
    goneRef.current = Object.assign({}, goneRef.current, away);
    if (replyTo && away[replyTo.id]) setReplyTo(null);
    totalRef.current = n;
    setGoneState(goneRef.current); setTotal(n); setSaid(line);
    tell(rowsRef.current !== null ? rowsRef.current : (ev.talk || []), n);
    // The control that had focus has just been taken away with its comment.
    setTimeout(() => { const to = ta.current || btn.current; if (to) to.focus(); }, 0);
  };
  const remove = (c) => call("community_comment_remove", { p_id: c.id }).then((d) => {
    // Two removals answer in either order, each with the count as it stood for
    // that one. A removal only ever lowers it, so the lower figure is the later.
    // (Not "one fewer, or the server's figure, whichever is lower": two answers
    // each took one off as well, and two removals left a count of one too few.)
    if (!live.current) return;
    drop(c, d.comments != null ? Math.min(totalRef.current, Number(d.comments) || 0) : Math.max(0, totalRef.current - 1), c.mine ? "Your comment has been deleted." : "Comment removed.");
    // More on the server than are showing here: someone has commented since
    // this thread was read. Read it again rather than offer "earlier comments"
    // that turn out to be later ones.
    if (rowsRef.current !== null && d.comments != null && Number(d.comments) > shown(rowsRef.current).length) load();
  }, (e) => {
    if (!live.current) throw e;
    // Refused because it is no longer there (its author deleted it first): the
    // server answers a missing comment and a forbidden one in the same words,
    // and the button is only ever offered to someone allowed to use it.
    if (String((e && e.message) || "") === "not_allowed") { drop(c, Math.max(0, totalRef.current - withKids(c)), CMTY_TALK_ERR.gone); return; }
    setErr(cmtyTalkErr(e)); throw e;
  });
  const report = (c) => call("community_comment_report", { p_id: c.id }).then(() => {
    if (!live.current) return;
    // Still there for everyone else until it is looked at; gone for this
    // reader, here and (the server sees to it) in their count and inbox.
    drop(c, Math.max(0, totalRef.current - withKids(c)), "Reported. Thank you, we’ll take a look. You won’t see this comment again.");
    window.BBR_track && window.BBR_track("community_report", {});
  }, (e) => { if (live.current) setErr(cmtyTalkErr(e)); throw e; });
  // Remembered only on the Community page: from a shelf page the reader is
  // brought back to the shelf, and the feed must not open this post later.
  const signIn = () => { if (/^\/community/.test(location.pathname)) { cmtyTalkWant.key = ev.key; cmtyTalkWant.at = Date.now(); } soc.gate(); };
  // Reply: signed out opens the gate, as the box would.
  const reply = (c) => {
    if (!meId) { signIn(); return; }
    start({ id: c.parent_id || c.id, name: cmtyName(c.actor || {}, null) });
  };

  const button = (
    <button className={"cmty-talk-btn" + (open ? " on" : "")} aria-expanded={open} ref={btn}
      aria-label={(total ? cmtyPlural(total, "comment") + " on " : "Comment on ") + (what || "this post") + (open ? ". Close the reply box" : total ? ". Read and reply" : "")}
      onClick={() => (open ? setOpen(false) : start())}>
      <CmtyBubble /> {total ? cmtyNum(total) : "Comment"}
    </button>
  );
  // Closed, what the preview stands for is its comments and their replies.
  const previewed = list.reduce((n, c) => n + 1 + (Number(c.replies) || (kids[c.id] || []).length), 0);
  const moreAbove = open ? (rows !== null && total > all.length) : total > previewed;
  const thread = (open || list.length || said) ? (
    <section className="cmty-talk" aria-label={"Comments on " + (what || "this post")}>
      {!open && moreAbove ? <button className="cmty-link cmty-talk-all" onClick={start}>View all {cmtyNum(total)} comments</button> : null}
      {open && rows === null && !err ? <p className="cmty-meta">Loading the comments…</p> : null}
      {open && moreAbove ? <button className="cmty-link cmty-talk-all" disabled={older} onClick={earlier}>{older ? "Loading…" : "Show earlier comments"}</button> : null}
      {list.length ? (
        <ul className="cmty-cs">
          {list.map((c) => {
            const mine = kids[c.id] || [];
            // Closed, a comment's replies are a count that opens the thread
            // (the feed's preview does not carry them).
            const hidden = open ? Math.max(0, (Number(c.replies) || 0) - mine.length) : (Number(c.replies) || mine.length);
            return (
              <CmtyComment key={c.id} c={c} meId={meId} soc={soc} onViewCollection={onViewCollection} onRemove={remove} onReport={report}
                onReply={reply} hidden={open && rows !== null ? 0 : hidden} onShowReplies={() => start(null, true)}
                replies={open ? mine.map((r) => <CmtyComment key={r.id} c={r} meId={meId} soc={soc} onViewCollection={onViewCollection}
                  onRemove={remove} onReport={report} onReply={reply} />) : null} />
            );
          })}
        </ul>
      ) : null}
      {/* Always in the page, so what is put in it is announced. */}
      <p className="cmty-meta cmty-talk-said" role="status">{said}</p>
      {open ? (meId ? (
        <form className="cmty-say" onSubmit={(e) => { e.preventDefault(); post(); }}>
          {replyTo ? (
            <div className="cmty-say-to">
              <span>Replying to <b>{replyTo.name}</b></span>
              <button type="button" className="cmty-link" onClick={() => { setReplyTo(null); if (ta.current) ta.current.focus(); }}>Cancel</button>
            </div>
          ) : null}
          {tag.field(
            <textarea ref={ta} value={text} maxLength={CMTY_TALK_MAX} rows={1} data-grow="1"
              placeholder={replyTo ? "Write a reply. Type @ to tag a collector." : total ? "Add a comment. Type @ to tag a collector." : "Be the first to comment"}
              aria-label={replyTo ? "Write a reply to " + replyTo.name : "Write a comment on " + (what || "this post")}
              onChange={(e) => { setText(e.target.value); if (err) setErr(null); }}
              onKeyDown={(e) => { if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) { e.preventDefault(); post(); } }} />
          )}
          <div className="cmty-say-row">
            <span className="cmty-meta" aria-live="polite">{text.length > CMTY_TALK_MAX - 100 ? (CMTY_TALK_MAX - text.length) + " left" : ""}</span>
            <button type="submit" className="cmty-follow on" disabled={busy || !text.trim()}>{busy ? "Posting…" : replyTo ? "Reply" : "Post"}</button>
          </div>
        </form>
      ) : (
        <p className="cmty-say-out"><button className="cmty-link" onClick={signIn}>Sign in or create a free account</button> to join in.</p>
      )) : null}
      {err ? (
        <p className="cmty-say-err" role="alert">{err.text}{" "}
          {err.code === "need_record" && window.__nav ? <button type="button" className="cmty-link" onClick={() => window.__nav.goCollection()}>Add a record</button> : null}
          {err.code === "load" ? <button type="button" className="cmty-link" onClick={() => { setErr(null); load(); }}>Try again</button> : null}
        </p>
      ) : null}
    </section>
  ) : null;
  return { button, thread, open };
}
// What a post is, said in a few words, for a line in the inbox.
function cmtyPostName(target, mine) {
  const t = String(target || "");
  const day = (iso) => { const d = new Date(iso + "T12:00:00"); return isNaN(d) ? "" : d.toLocaleDateString("en-GB", { day: "numeric", month: "short" }); };
  if (t.indexOf("add:") === 0) return (mine ? "the records you added" : "a collector’s new records") + (day(t.split(":")[2]) ? " on " + day(t.split(":")[2]) : "");
  if (t.indexOf("tier:") === 0) return "the market moves" + (day(t.split(":")[1]) ? " of " + day(t.split(":")[1]) : "");
  if (t.indexOf("dive:") === 0) {
    const d = window.bbrDiveFor ? window.bbrDiveFor(t.slice(5)) : null;
    return d && d.title ? "the deep dive on " + d.title : "a deep dive";
  }
  if (t.indexOf("post:") === 0) return mine ? "your post" : "a post";
  return "a post";
}

// ---- what happened on your shelf --------------------------------------------
// community_inbox() for the signed-in reader, shared by the nav badge and the
// panel at the top of the feed. Opening the feed reads it AGAIN and marks seen
// only as far as it actually showed: it used to be read once per page load and
// marked seen with the server's clock, so a like that landed in between was
// marked seen without ever being shown, and (the email only sends what is
// unseen) was never emailed either (QA, 12 Oct 2026).
const cmtyInbox = { uid: null, data: null, subs: new Set(), req: 0, at: 0, failed: false };
function cmtyInboxSet(data) { cmtyInbox.data = data; cmtyInbox.subs.forEach((f) => f(data)); }
function cmtyInboxLoad(uid, again) {
  uid = uid || null;
  const same = cmtyInbox.uid === uid;
  if (same && !cmtyInbox.failed && !(again && Date.now() - cmtyInbox.at > 5000)) return;
  cmtyInbox.uid = uid;
  if (!same) cmtyInboxSet(null);
  if (!uid || !window.BBR_supabase) return;
  const n = ++cmtyInbox.req;
  cmtyInbox.at = Date.now();
  cmtyInbox.failed = false;
  Promise.resolve(window.BBR_supabase.rpc("community_inbox", {})).then((out) => {
    if (cmtyInbox.uid !== uid || n !== cmtyInbox.req) return;
    // A failed read is remembered so the next mount asks again, not never.
    if (!out || out.error || !out.data) { cmtyInbox.failed = true; return; }
    cmtyInboxSet({ unseen: Number(out.data.unseen) || 0, items: out.data.items || [] });
  }, () => { if (n === cmtyInbox.req) cmtyInbox.failed = true; });
}
function useCmtyInbox(uid) {
  const [data, setData] = useCmState(cmtyInbox.uid === (uid || null) ? cmtyInbox.data : null);
  useCmEffect(() => {
    cmtyInbox.subs.add(setData);
    cmtyInboxLoad(uid);
    setData(cmtyInbox.data);
    return () => { cmtyInbox.subs.delete(setData); };
  }, [uid]);
  return data;
}
// The count beside "Community" in the nav. Nothing at all when there is nothing new.
function CommunityNavBadge({ userId }) {
  const data = useCmtyInbox(userId || null);
  if (!data || !data.unseen) return null;
  return <span className="cmty-navbadge"><span aria-hidden="true">{data.unseen > 9 ? "9+" : data.unseen}</span><span className="cmty-vh"> ({data.unseen} new for you)</span></span>;
}
function cmtyInboxLine(it) {
  if (it.kind === "follow") return "started following you";
  if (it.kind === "comment") return "commented on " + cmtyPostName(it.target, true);
  if (it.kind === "also") return "also commented on " + cmtyPostName(it.target, false);
  if (it.kind === "reply") return "replied to your comment on " + cmtyPostName(it.target, false);
  if (it.kind === "mention") return "tagged you in a post";
  if (it.kind === "mention_comment") return "tagged you in a comment on " + cmtyPostName(it.target, false);
  const t = String(it.target || "");
  if (t.indexOf("post:") === 0) return "liked your post";
  if (t.indexOf("comment:") === 0) return "liked your comment";
  if (t.indexOf("add:") === 0) {
    const d = new Date(t.split(":")[2] + "T12:00:00");
    if (!isNaN(d)) return "liked the records you added on " + d.toLocaleDateString("en-GB", { day: "numeric", month: "short" });
  }
  return "liked your shelf";
}
// Where a line leads (14 Oct 2026, Ric: a notification has to take you to
// the thing it is about). A follow: the follower's shelf. A like on your
// shelf: your shelf. Anything on a post: that post, and the comment when the
// line is about one, shown and lit.
function cmtyInboxGo(it, meId) {
  if (it.kind === "follow") return { shelf: it.actor && it.actor.user_id };
  const t = String(it.target || "");
  if (it.kind === "like" && t.indexOf("shelf:") === 0) return { shelf: meId };
  const post = it.post || (t.indexOf("comment:") === 0 ? null : t);
  return post ? { post, cid: it.comment_id || null } : null;
}
const CMTY_INBOX_K = { like: "♥", follow: "+", comment: "“", also: "“", reply: "↩", mention: "@", mention_comment: "@" };
const CMTY_INBOX_FIRST = 3;
// Kept for the way back: "Show all" stays open, and the line that was tapped
// gets focus again (UX review, 14 Oct 2026: Back landed mid-feed, collapsed).
const cmtyInboxKeep = { all: false, from: -1 };
function cmtyInboxReturn() {
  const i = cmtyInboxKeep.from;
  const btn = i >= 0 ? document.querySelectorAll(".cmty-inbox-list > li")[i] : null;
  const el = (btn && btn.querySelector(".cmty-inbox-row")) || document.getElementById("cmty-inbox-h");
  if (!el) return;
  try { el.scrollIntoView({ block: "center" }); } catch (e) {}
  if (el.focus) el.focus({ preventScroll: true });
}
function CmtyInboxPanel({ meId, onViewCollection, onOpenPost }) {
  const data = useCmtyInbox(meId || null);
  const [all, setAllState] = useCmState(cmtyInboxKeep.all);
  const setAll = (v) => { cmtyInboxKeep.all = v; setAllState(v); };
  useCmEffect(() => { if (meId) cmtyInboxLoad(meId, true); }, [meId]);
  // ON SCREEN, not merely in the page (a reader who never scrolled to it has
  // not seen it), and only once every new line is showing: marking seen
  // clears everything up to the newest, so new lines folded under "Show all"
  // would have been cleared unread.
  const boxRef = useCmRef(null);
  const [inView, setInView] = useCmState(false);
  useCmEffect(() => {
    if (inView || !boxRef.current) return;
    return cmtyWhenVisible(boxRef.current, () => setInView(true));
  }, [inView, !!(data && data.items.length)]);
  const items = (data && data.items) || [];
  const fresh = items.filter((it) => it.new).length;
  // Short by default (Ric, 14 Oct 2026: on a phone it ran a screen and more).
  const rows = all ? items : items.slice(0, CMTY_INBOX_FIRST);
  const allNewShown = rows.filter((it) => it.new).length >= fresh;
  const newest = items[0] ? items[0].at : null;
  useCmEffect(() => {
    if (!data || !data.unseen || !newest || !window.BBR_supabase || !inView || !allNewShown) return;
    const t = setTimeout(() => {
      Promise.resolve(window.BBR_supabase.rpc("community_inbox_seen", { p_upto: newest })).then((out) => {
        if (out && !out.error && cmtyInbox.data) cmtyInboxSet({ ...cmtyInbox.data, unseen: 0 });
      }, () => {});
    }, 1500);
    return () => clearTimeout(t);
  }, [data && data.unseen, newest, inView, allNewShown]);
  // A member with nothing yet learns what will show here.
  if (!items.length) {
    return data ? (
      <section className="cmty-inbox cmty-inbox-empty" aria-labelledby="cmty-inbox-h">
        <div className="cmty-inbox-top"><h2 id="cmty-inbox-h">For you</h2></div>
        <p className="cmty-meta">When someone follows you, likes your post, replies to you or tags you, it shows here.</p>
      </section>
    ) : null;
  }
  const go = (it, i) => {
    const to = cmtyInboxGo(it, meId);
    if (!to) return;
    cmtyInboxKeep.from = i;
    if (to.shelf) onViewCollection(to.shelf, "");
    else if (onOpenPost) onOpenPost(to.post, to.cid, true);
  };
  // "For you", not "On your shelf": it carries replies on threads as well as
  // likes and follows, and an account with no shelf at all can be in a thread.
  return (
    <section className="cmty-inbox" aria-labelledby="cmty-inbox-h" ref={boxRef}>
      <div className="cmty-inbox-top">
        <h2 id="cmty-inbox-h">For you</h2>
        {data.unseen ? <span className="cmty-inbox-n">{data.unseen > 99 ? "99+" : data.unseen} new</span> : null}
      </div>
      <ul className="cmty-inbox-list">
        {rows.map((it, i) => {
          const to = cmtyInboxGo(it, meId);
          const who = cmtyName(it.actor, null);
          const inner = (
            <React.Fragment>
              <span className="cmty-inbox-av" aria-hidden="true">
                <CmtyAvatar actor={it.actor} size={36} />
                <span className={"cmty-inbox-k k-" + it.kind}>{CMTY_INBOX_K[it.kind] || "•"}</span>
              </span>
              <span className="cmty-inbox-txt">
                <span className="cmty-inbox-l"><b>{who}</b> {cmtyInboxLine(it)}</span>
                {it.text ? <span className="cmty-inbox-q" dir="auto">“{it.text}”</span> : null}
                <span className="cmty-inbox-when">{cmtyTalkWhen(it.at)}{it.new ? <span className="cmty-vh"> (new)</span> : null}</span>
              </span>
            </React.Fragment>
          );
          return (
            <li key={i} className={it.new ? "new" : ""}>
              {to ? <button type="button" className="cmty-inbox-row" onClick={() => go(it, i)}>{inner}</button>
                  : <div className="cmty-inbox-row">{inner}</div>}
            </li>
          );
        })}
      </ul>
      {items.length > CMTY_INBOX_FIRST ? (
        <button type="button" className="cmty-link cmty-inbox-more" aria-expanded={all} onClick={() => setAll(!all)}>
          {all ? "Show less" : "Show all " + items.length + (fresh > rows.filter((it) => it.new).length ? " (" + (fresh - rows.filter((it) => it.new).length) + " more new)" : "")}
        </button>
      ) : null}
    </section>
  );
}

// The three tabs. Real links, so each is crawlable and cmd-clicks like the nav.
function CommunityTabs({ tab }) {
  const nav = window.__nav || {};
  const to = (href, fn) => ({
    href,
    onClick: (e) => {
      if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey || e.button !== 0 || !fn) return;
      e.preventDefault();
      fn();
    },
  });
  const cls = (k) => "cmty-tab" + (tab === k ? " active" : "");
  const cur = (k) => (tab === k ? "page" : undefined);
  return (
    <nav className="cmty-tabs" aria-label="Community">
      <a className={cls("feed")} aria-current={cur("feed")} {...to("/community", nav.goCommunity)}>Activity</a>
      <a className={cls("collectors")} aria-current={cur("collectors")} {...to("/community/collectors", nav.goCollectors)}>Collectors</a>
      <a className={cls("leaderboards")} aria-current={cur("leaderboards")} {...to("/leaderboards", nav.goLeaderboards)}>Leaderboards</a>
    </nav>
  );
}

function CmtyOwners({ owners, count, meId, lone, slug, title, onViewCollection }) {
  const [all, setAll] = useCmState(false);
  if (!count) return null;
  const list = owners || [];
  const text = count === 1 && list[0]
    ? (lone || "On") + " " + (list[0].user_id === meId ? "your" : cmtyName(list[0], null) + "’s") + " shelf"
    : cmtyPlural(count, "collector") + " own" + (count === 1 ? "s" : "") + " it";
  return (
    <div className="cmty-owners">
      <span className="cmty-owners-av">{list.map((o) => <CmtyAvatar key={o.user_id} actor={o} size={22} />)}</span>
      {/* On a deep dive, who they all are (Ric, 14 Oct 2026). */}
      {slug && window.BBR_supabase ? <button type="button" className="cmty-link cmty-owners-open" aria-haspopup="dialog" onClick={() => setAll(true)}>{text}</button> : <span>{text}</span>}
      {all ? <CmtyOwnersSheet slug={slug} title={title} meId={meId} onViewCollection={onViewCollection} onClose={() => setAll(false)} /> : null}
    </div>
  );
}

// "Scarce → Rare", with the same thing in words for a screen reader: the old
// tier was a strikethrough on a wide screen and not there at all on a phone.
function CmtyTierMove({ it }) {
  return (
    <span className="cmty-tiermove">
      <span className="cmty-vh">Moved from {cmtyTier(it.from)} to {cmtyTier(it.to)}</span>
      <span className="cmty-tier-from" aria-hidden="true">{cmtyTier(it.from)}</span>
      <span className="cmty-tier-arr" aria-hidden="true">&rarr;</span>
      <span className={"tier-chip tier-chip-" + it.to} aria-hidden="true">{cmtyTier(it.to)}</span>
    </span>
  );
}

function CmtyAddsPost({ ev, meId, soc, onViewCollection, onTalk, focus }) {
  const dated = React.useContext(CmtyDated);
  const a = ev.actor || {};
  const talk = useCmtyTalk(ev, meId, soc, onViewCollection, onTalk, focus, cmtyName(a, null) + "’s new records");
  const name = cmtyName(a, meId);
  const items = ev.items || [];
  const open = () => onViewCollection(a.user_id, cmtyName(a, null));
  const level = ev.level ? cmtyLevel(ev.level) : null;
  let did;
  if (level) did = <React.Fragment>reached <b>{level}</b> with {cmtyPlural(ev.level, "record")}</React.Fragment>;
  else if (ev.first && a.is_public) did = <React.Fragment>started a shelf with {cmtyPlural(ev.n, "record")}</React.Fragment>;
  else did = <React.Fragment>added {ev.n === 1 ? "a record" : cmtyPlural(ev.n, "record")}</React.Fragment>;
  const more = ev.n - items.length;
  // The link is only offered for a record that really has a dive.
  const isDive = (slug) => !!(slug && window.bbrDiveFor && window.bbrDiveFor(slug));
  // One record opened at a time, over the page.
  const [rec, setRec] = useCmState(null);
  return (
    <article className={"cmty-post" + (level ? " milestone" : "")}>
      {level ? <div className="cmty-ribbon"><span aria-hidden="true">&#9733;</span> Level up · {level}</div> : null}
      <header className="cmty-who">
        <CmtyAvatar actor={a} />
        <div className="cmty-line">
          <button className="cmty-name" onClick={open}>{name}</button> {did}
          <div className="cmty-meta">
            {cmtyPostWhen(ev.at, dated)} · {cmtyPlural(a.item_count, "record")} · {cmtyGbp(a.total_value)}
            {!level && cmtyLevel(a.item_count) ? " · " + cmtyLevel(a.item_count) : ""}
          </div>
        </div>
        <CmtyFollow soc={soc} user={a} />
      </header>
      {items.length === 1 ? (
        <div className="cmty-one big">
          <button className="cmty-one-open" onClick={() => setRec(items[0])} aria-label={items[0].title + " by " + items[0].artist + ". Open"}>
            <CmtySleeve item={items[0]} size={132} />
          </button>
          <div>
            <button className="cmty-one-t cmty-one-btn" onClick={() => setRec(items[0])}>{items[0].title}</button>
            <div className="cmty-meta">{items[0].artist}{items[0].year ? " · " + items[0].year : ""}</div>
            {isDive(items[0].slug) && window.__nav ? <button className="cmty-link" onClick={() => window.__nav.goDeepDive(items[0].slug)}>Read the deep dive</button> : null}
          </div>
        </div>
      ) : items.length ? (
        // Every record of the day (up to twelve), each one its own button. It
        // was a mosaic of four, the smallest 60 pixels, that opened the shelf
        // and nothing else (Ric, 13 Oct 2026: "you can't see what they've
        // added"). The most valuable leads, as before.
        <CmtyRecordStrip items={items} more={more} actor={a} meId={meId} onOpen={setRec} onShelf={open} />
      ) : null}
      {rec ? <CmtyRecordSheet rec={rec} actor={a} meId={meId} onClose={() => setRec(null)} onViewCollection={onViewCollection} /> : null}
      <footer className="cmty-acts">
        <span className="cmty-acts-l">
          <CmtyLike soc={soc} target={ev.key} base={ev} mine={!!(meId && a.user_id === meId)} what={cmtyName(a, null) + "’s new records"} />
          {talk.button}
        </span>
        <button className="cmty-link" onClick={open}>View shelf</button>
      </footer>
      {talk.thread}
    </article>
  );
}

// ---- collector posts (13 Oct 2026) -------------------------------------------
// Ric: collectors should be able to post to the feed, with ideas for what to
// post about, and with photos and videos. A post is live at once; the author
// deletes it, anyone signed in reports it, a moderator removes it. Up to four
// photos, or one video of at most a minute and 50 MB; optionally a record
// from the author's shelf or a deep dive. The server's rules are in
// supabase/migrations/community_posts.sql (community_post_add).
const CMTY_PROMPTS = [
  { k: "spinning", label: "Now spinning", ask: "What’s on the turntable right now?" },
  { k: "bought", label: "Just bought", ask: "What did you pick up, and where did you find it?" },
  { k: "opinion", label: "Hot take", ask: "A record, a pressing, an artist: what do you really think?" },
  { k: "setup", label: "My setup", ask: "Show us where the records live and what plays them." },
  { k: "question", label: "Ask collectors", ask: "What would you like to ask other collectors?" },
];
const CMTY_POST_MAX = 2000;
const CMTY_POST_ERR = {
  slow_down: "That’s a lot of posts in a short while. Try again later.",
  too_long: "That’s over 2,000 characters.",
  empty: "Write something, or add a photo, a video or a record.",
  bad_media: "One of the files didn’t upload properly. Remove it and try again.",
  too_long_video: "Videos can be up to a minute long.",
  not_your_record: "That record isn’t on your shelf any more.",
  bad_record: "That record can’t be added. Choose another.",
  sign_in: "Sign in again to post.",
  not_allowed: "This account can’t post.",
};
// What Storage says when an upload is refused, in words: too big, or (the
// policy's daily limit) too many today. Null when it is something else.
function cmtyUploadErr(e) {
  const m = String((e && e.message) || ""), c = String((e && (e.statusCode || e.status)) || "");
  if (c === "413" || /too large|exceeded the maximum/i.test(m)) return "That file is too big. Videos can be up to 50 MB.";
  // A session that has run out reads as a refusal too; say what it is.
  if (/jwt|PGRST301|token.*expired/i.test(m)) return "You’ve been signed out. Sign in again and try once more.";
  if (/row-level security|violates/i.test(m)) return "That upload was refused. If you’ve uploaded a lot today, try again tomorrow.";
  return null;
}
function cmtyPostErr(e) {
  const m = String((e && e.message) || "");
  return CMTY_POST_ERR[m] || cmtyUploadErr(e) || "That didn’t post. Try again in a moment.";
}

// Photos and a video, full screen. Arrow keys and a swipe move between photos.
function CmtyMediaViewer({ media, start, onClose }) {
  const list = (media || []).filter((m) => m && m.path);
  const [i, setI] = useCmState(Math.max(0, Math.min(start || 0, list.length - 1)));
  const boxRef = useCmRef(null);
  useCmtyDialog(boxRef, onClose, "bbrCmtyMedia");
  const go = (d) => setI((n) => (n + d + list.length) % list.length);
  useCmEffect(() => {
    const k = (e) => { if (e.key === "ArrowRight") go(1); else if (e.key === "ArrowLeft") go(-1); };
    window.addEventListener("keydown", k);
    return () => window.removeEventListener("keydown", k);
  }, [list.length]);
  const touch = useCmRef(null);
  const m = list[i];
  if (!m) return null;
  const portal = window.bbrOverlayPortal || ((n) => n);
  return portal(
    <div className="cmty-mv" role="dialog" aria-modal="true" aria-label={list.length > 1 ? "Photo " + (i + 1) + " of " + list.length : "Photo"} tabIndex={-1} ref={boxRef}
      onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}
      onTouchStart={(e) => { touch.current = e.touches[0] ? e.touches[0].clientX : null; }}
      onTouchEnd={(e) => { const x0 = touch.current, x1 = e.changedTouches[0] ? e.changedTouches[0].clientX : null; touch.current = null; if (x0 != null && x1 != null && Math.abs(x1 - x0) > 50 && list.length > 1) go(x1 < x0 ? 1 : -1); }}>
      <button className="cmty-mv-x" aria-label="Close" onClick={onClose}>×</button>
      {m.kind === "video"
        ? <video className="cmty-mv-m" src={cmtyMediaUrl(m.path)} poster={m.poster ? cmtyMediaUrl(m.poster) : undefined} controls playsInline autoPlay />
        : <img className="cmty-mv-m" src={cmtyMediaUrl(m.path)} alt="" />}
      {list.length > 1 ? (
        <React.Fragment>
          <button className="cmty-mv-p" aria-label="Previous photo" onClick={() => go(-1)}>‹</button>
          <button className="cmty-mv-n" aria-label="Next photo" onClick={() => go(1)}>›</button>
          <span className="cmty-mv-c" aria-hidden="true">{i + 1} / {list.length}</span>
        </React.Fragment>
      ) : null}
    </div>
  );
}

// In the post: one video as it is, or up to four photos in a grid, each of
// which opens full screen.
function CmtyMediaGallery({ media, onOpen, label }) {
  const list = (media || []).filter((m) => m && m.path);
  if (!list.length) return null;
  const ratio = (m) => (m.w && m.h ? m.w + " / " + m.h : undefined);
  if (list[0].kind === "video") {
    const m = list[0];
    return (
      <div className="cmty-vid" style={{ "--ar": ratio(m) || "16 / 9" }}>
        <video src={cmtyMediaUrl(m.path)} poster={m.poster ? cmtyMediaUrl(m.poster) : undefined} controls playsInline preload="metadata"
          aria-label={"Video" + (label ? " from " + label : "")} />
      </div>
    );
  }
  const shown = list.slice(0, 4);
  return (
    <div className={"cmty-gal n" + shown.length} style={shown.length === 1 ? { "--ar": ratio(shown[0]) || "4 / 3" } : undefined}>
      {shown.map((m, i) => (
        <button key={m.path} className="cmty-gal-i" onClick={() => onOpen(i)}
          aria-label={"Open photo " + (i + 1) + " of " + shown.length + (label ? " from " + label : "")}>
          <img src={cmtyMediaUrl(m.path)} alt="" loading="lazy" decoding="async" />
        </button>
      ))}
    </div>
  );
}

// A record named in a post: a small card that opens the record sheet.
function CmtyPostRecord({ rec, onOpen }) {
  return (
    <button className="cmty-up-rec" onClick={onOpen} aria-label={rec.title + " by " + rec.artist + ". Open"}>
      <CmtySleeve item={rec} size={52} />
      <span className="cmty-up-rec-t">
        <b>{rec.title}</b>
        <span className="cmty-meta">{rec.artist}{rec.year ? " · " + rec.year : ""}{rec.owned === false ? " · Deep dive" : ""}</span>
      </span>
      <span className="cmty-up-rec-go" aria-hidden="true">›</span>
    </button>
  );
}

function CmtyUserPost({ ev, meId, soc, onViewCollection, onTalk, focus, onGone }) {
  const dated = React.useContext(CmtyDated);
  const a = ev.actor || {};
  const name = cmtyName(a, meId);
  const talk = useCmtyTalk(ev, meId, soc, onViewCollection, onTalk, focus, cmtyName(a, null) + "’s post");
  const [view, setView] = useCmState(-1);
  const [rec, setRec] = useCmState(null);
  const [ask, setAsk] = useCmState(false);        // false | "remove" | "report"
  const [busy, setBusy] = useCmState(false);
  const [gone, setGone] = useCmState(null);       // "removed" | "reported"
  const [err, setErr] = useCmState("");
  const [full, setFull] = useCmState(false);
  const noRef = useCmRef(null), goneRef = useCmRef(null), askBtn = useCmRef(null), wasAsk = useCmRef(false);
  // To the way out when asked; back to the link if the answer is no.
  useCmEffect(() => {
    if (ask && noRef.current) noRef.current.focus();
    else if (!ask && wasAsk.current && askBtn.current) askBtn.current.focus();
    wasAsk.current = !!ask;
  }, [ask]);
  const pr = CMTY_PROMPTS.find((p) => p.k === ev.prompt);
  const text = String(ev.text || "");
  const long = text.length > 420 || text.split("\n").length > 8;
  const open = () => onViewCollection(a.user_id, "");
  const act = () => {
    const kind = ask;
    setBusy(true); setErr("");
    Promise.resolve(window.BBR_supabase.rpc(kind === "report" ? "community_post_report" : "community_post_remove", { p_id: ev.id })).then((out) => {
      if (!out || out.error) throw (out && out.error) || new Error("failed");
      setGone(kind === "report" ? "reported" : "removed");
      // Not to come back with Back, or on the other page that shows it.
      if (cmtyFeedKeep.events) cmtyFeedKeep.events = cmtyFeedKeep.events.filter((e) => e.key !== ev.key);
      if (onGone) onGone(ev.key);
      setTimeout(() => { if (goneRef.current) goneRef.current.focus(); }, 0);
      window.BBR_track && window.BBR_track(kind === "report" ? "community_report" : "community_post_remove", { kind: "post" });
    }).catch((e) => {
      const code = String((e && e.message) || "");
      setBusy(false); setAsk(false);
      // Deleted or reported in its other copy on the page (the opened post and
      // the list): retrying could never work (QA, 14 Oct 2026).
      if (code === "not_allowed") { setGone("removed"); return; }
      setErr(code === "slow_down" ? "That’s as many reports as one account can make in a day. Thank you; try again tomorrow."
        : "That didn’t go through. Try again in a moment.");
    });
  };
  if (gone) {
    return (
      <article className="cmty-post cmty-post-gone" role="status" tabIndex={-1} ref={goneRef}>
        <p className="cmty-meta" dir="auto">{gone === "reported" ? "Reported. Thank you, we’ll take a look. You won’t see this post again." : ev.mine ? "Your post has been deleted." : "Post removed."}</p>
      </article>
    );
  }
  // An account with no records can post, but there is no shelf to follow.
  const followable = Number(a.item_count) > 0 ? a : null;
  const canReport = !!meId && !ev.mine && !ev.can_remove;
  return (
    <article className="cmty-post cmty-up">
      <header className="cmty-who">
        <CmtyAvatar actor={a} />
        <div className="cmty-line">
          <button className="cmty-name" onClick={open}>{name}</button>
          {pr ? <span className="cmty-up-k">{pr.label}</span> : null}
          <div className="cmty-meta">
            {cmtyPostWhen(ev.at, dated)}{Number(a.item_count) > 0 ? " · " + cmtyPlural(a.item_count, "record") : ""}
          </div>
        </div>
        <CmtyFollow soc={soc} user={followable} />
      </header>
      {text ? <CmtyRich className={"cmty-up-text" + (long && !full ? " clip" : "")} text={text} mentions={ev.mentions} onOpen={onViewCollection} /> : null}
      {long ? <button className="cmty-link cmty-up-more" aria-expanded={full} onClick={() => setFull(!full)}>{full ? "Show less" : "Read more"}</button> : null}
      <CmtyMediaGallery media={ev.media} onOpen={setView} label={cmtyName(a, null)} />
      {ev.record ? <CmtyPostRecord rec={ev.record} onOpen={() => setRec(ev.record)} /> : null}
      <footer className="cmty-acts">
        <span className="cmty-acts-l">
          <CmtyLike soc={soc} target={ev.key} base={ev} mine={!!ev.mine} what={cmtyName(a, null) + "’s post"} />
          {talk.button}
        </span>
        <span className="cmty-up-end">
          {ask ? (
            <CmtyAsk q={ask === "report" ? "Report this post to the moderators?" : ev.mine ? "Delete your post?" : "Remove this post for everyone?"}
              yes={ask === "report" ? "Report" : ev.mine ? "Delete" : "Remove"} no={ask !== "report" && ev.mine ? "Keep" : "Cancel"}
              busy={busy} noRef={noRef} onYes={act} onNo={() => setAsk(false)} />
          ) : ev.can_remove ? <button className="cmty-link" ref={askBtn} onClick={() => setAsk("remove")}>{ev.mine ? "Delete" : "Remove"}</button>
            : canReport ? <button className="cmty-link" ref={askBtn} onClick={() => setAsk("report")}>Report</button> : null}
        </span>
      </footer>
      {err ? <p className="cmty-say-err" role="alert">{err}</p> : null}
      {talk.thread}
      {view >= 0 ? <CmtyMediaViewer media={ev.media} start={view} onClose={() => setView(-1)} /> : null}
      {rec ? <CmtyRecordSheet rec={rec} actor={rec.owned === false ? null : a} meId={meId} onClose={() => setRec(null)} onViewCollection={onViewCollection} /> : null}
    </article>
  );
}

// Who the writer will appear as, from the store when it is theirs.
function cmtyMeLabel(meId) {
  const st = window.BBR_store;
  if (!st || !meId || String(st.userId) !== String(meId) || !st.profileLoaded) return "";
  return st.isPublic && st.username ? st.username : "Anonymous " + (st.anonAlias || "collector");
}

// WRITING A POST. Closed, it is a line to tap and five ideas; open, a box, the
// attachments and Post. `items` are the writer's own records, for "about a
// record"; when not given, the store's are used if they are the writer's.
function CmtyComposer({ meId, authReady, items, onPosted, where }) {
  const [open, setOpen] = useCmState(false);
  const [prompt, setPrompt] = useCmState(null);
  const [text, setText] = useCmState("");
  const [files, setFiles] = useCmState([]);       // [{ id, kind, blob|file, url, w, h, dur, poster, type }]
  const [rec, setRec] = useCmState(null);          // { kind: "item"|"dive", ... }
  const [recOpen, setRecOpen] = useCmState(false);
  const [q, setQ] = useCmState("");
  const [busy, setBusy] = useCmState(false);
  const [stage, setStage] = useCmState("");
  const [err, setErr] = useCmState("");
  const [picking, setPicking] = useCmState(false);
  const [said, setSaid] = useCmState("");
  // Which press of Post (or which reading of a file) is current. Cancel moves
  // it on, so an upload still under way from before can neither post nor put
  // its files back: one shared "stopped" flag was reset by the next Post, and a
  // cancelled post went out while the new one was dropped (QA, 14 Oct 2026).
  const runRef = useCmRef(0);
  const [discard, setDiscard] = useCmState(false);
  const keepRef = useCmRef(null);
  const taRef = useCmRef(null), photoRef = useCmRef(null), videoRef = useCmRef(null), fakeRef = useCmRef(null);
  const tag = useCmtyTag(taRef, text, setText, null, meId);
  // The writer's own picture beside the box, in place of "Posting as …"
  // (Ric, 14 Oct 2026); the name is still said to a screen reader.
  const meActor = useCmtyMeActor(meId || null);
  const filesRef = useCmRef(files); filesRef.current = files;
  // Object URLs for the previews are let go when the box is.
  useCmEffect(() => () => { (filesRef.current || []).forEach((f) => { if (f.url) URL.revokeObjectURL(f.url); }); }, []);
  const gate = () => { if (authReady === false) return; window.__bbrRequireAuth && window.__bbrRequireAuth("community"); };
  const begin = (k) => {
    if (!meId) { gate(); return; }
    setOpen(true); setSaid("");
    if (k !== undefined) setPrompt((p) => (p === k ? null : k));
    setTimeout(() => { if (taRef.current) taRef.current.focus(); }, 30);
    window.BBR_track && window.BBR_track("community_compose", { prompt: k || "none", where: where || "feed" });
  };
  const pr = CMTY_PROMPTS.find((p) => p.k === prompt);
  const hasVideo = files.some((f) => f.kind === "video");
  const nPhotos = files.filter((f) => f.kind === "image").length;

  const addPhotos = (list) => {
    const room = 4 - nPhotos;
    const all = Array.from(list || []).filter((f) => /^image\//.test(f.type) || /\.(jpe?g|png|webp|heic|heif|gif)$/i.test(f.name || ""));
    const pick = all.slice(0, Math.max(0, room));
    if (!pick.length) { if (room <= 0) setErr("Up to four photos in a post."); return; }
    const run = runRef.current;
    setErr(""); setPicking(true);
    Promise.all(pick.map((f) => cmtyImageBlob(f, { max: 1600 }).then((r) => ({ id: cmtyRandName(), kind: "image", blob: r.blob, w: r.w, h: r.h, url: URL.createObjectURL(r.blob) }), () => null)))
      .then((got) => {
        const ok = got.filter(Boolean);
        if (run !== runRef.current) { ok.forEach((x) => URL.revokeObjectURL(x.url)); return; }
        setPicking(false);
        if (ok.length < got.length) setErr(got.length === 1 ? "That photo couldn’t be read. Try a JPEG or PNG." : "Some of those photos couldn’t be read. Try JPEG or PNG.");
        else if (all.length > pick.length) setErr(pick.length === 1 ? "Up to four photos in a post: one more was added." : "Up to four photos in a post: the first " + pick.length + " were added.");
        setFiles((fs) => fs.filter((x) => x.kind === "image").concat(ok).slice(0, 4));
      });
  };
  const addVideo = (f) => {
    if (!f) return;
    const type = cmtyVideoType(f);
    if (!type) { setErr("That video format isn’t supported. Try an MP4."); return; }
    if (f.size > CMTY_VIDEO_MAX_BYTES) { setErr("Videos can be up to 50 MB. That one is " + Math.round(f.size / 1048576) + " MB."); return; }
    const run = runRef.current;
    setErr(""); setPicking(true);
    cmtyVideoInfo(f).then((info) => {
      if (run !== runRef.current) { URL.revokeObjectURL(info.url); return; }
      setPicking(false);
      if (info.dur > CMTY_VIDEO_MAX_S + 0.5) { URL.revokeObjectURL(info.url); setErr("Videos can be up to a minute long. That one is " + Math.round(info.dur) + " seconds."); return; }
      filesRef.current.forEach((x) => { if (x.url) URL.revokeObjectURL(x.url); });
      setFiles([{ id: cmtyRandName(), kind: "video", file: f, type, url: info.url, w: info.w, h: info.h, dur: info.dur, poster: info.poster }]);
    }, () => { if (run !== runRef.current) return; setPicking(false); setErr("We couldn’t read that video. Try an MP4 recorded on your phone."); });
  };
  const dropFile = (id) => setFiles((fs) => { const f = fs.find((x) => x.id === id); if (f && f.url) URL.revokeObjectURL(f.url); return fs.filter((x) => x.id !== id); });

  // Records to choose from: your shelf, then the deep dives.
  const mine = items || ((window.BBR_store && meId && String(window.BBR_store.userId) === String(meId) && window.BBR_store.collectionV2) || []);
  const recMatches = useCmMemo(() => {
    const t = q.trim().toLowerCase();
    if (t.length < 2) return [];
    const has = (r) => ((r.title || "") + " " + (r.artist || "")).toLowerCase().includes(t);
    const own = (mine || []).filter(has).slice(0, 5).map((r) => ({ kind: "item", id: r.id, title: r.title, artist: r.artist, year: r.year, slug: r.slug || null, discogs_release_id: r.discogs_release_id || null, cover: r.cover || (r.album && r.album.coverUrl) || null }));
    const dives = ((window.bbrDivesNow && window.bbrDivesNow()) || []).filter((d) => d.published_at && d.slug && has(d)).slice(0, 3)
      .map((d) => ({ kind: "dive", slug: d.slug, title: d.title, artist: d.artist, year: d.year }));
    return own.concat(dives);
  }, [q, mine]);

  const reset = () => {
    runRef.current++;
    setPicking(false);
    files.forEach((f) => { if (f.url) URL.revokeObjectURL(f.url); });
    setOpen(false); setPrompt(null); setText(""); setFiles([]); setRec(null); setRecOpen(false); setQ(""); setErr(""); setStage(""); tag.reset();
  };
  const canPost = !busy && !picking && (text.trim().length > 0 || files.length > 0 || !!rec) && text.length <= CMTY_POST_MAX;
  const submit = async () => {
    if (!canPost) return;
    if (!meId) { gate(); return; }
    const run = ++runRef.current;
    const over = () => run !== runRef.current;
    setBusy(true); setErr("");
    try {
      const media = [];
      for (let i = 0; i < files.length; i++) {
        const f = files[i];
        if (over()) return;
        setStage(files.length > 1 ? "Uploading " + (i + 1) + " of " + files.length + "…" : "Uploading…");
        // A file already sent (a retry after a later one failed) is not sent
        // again: each upload counts against the day's allowance.
        if (f.kind === "image") {
          f.to = f.to || cmtyUploadPath(meId, "jpg");
          if (!f.path) f.path = await cmtyUpload(f.to, f.blob, "image/jpeg");
          media.push({ kind: "image", path: f.path, w: f.w, h: f.h });
        } else {
          f.to = f.to || cmtyUploadPath(meId, CMTY_VIDEO_TYPES[f.type] || "mp4");
          if (!f.path) f.path = await cmtyUpload(f.to, await cmtyStripVideoLocation(f.file), f.type);
          if (over()) return;
          if (f.poster && !f.posterPath) {
            f.posterTo = f.posterTo || cmtyUploadPath(meId, "jpg");
            try { f.posterPath = await cmtyUpload(f.posterTo, f.poster, "image/jpeg"); } catch (e) { f.posterPath = null; }
          }
          media.push({ kind: "video", path: f.path, poster: f.posterPath || null, w: f.w, h: f.h, dur: Math.round(f.dur * 10) / 10 });
        }
      }
      if (over()) return;
      setStage("Posting…");
      const args = {
        p_body: text, p_prompt: prompt, p_item: rec && rec.kind === "item" ? rec.id : null,
        p_slug: rec && rec.kind === "dive" ? rec.slug : null, p_media: media,
      };
      const tags = tag.ids(text);
      if (tags.length) args.p_mentions = tags;
      const out = await Promise.resolve(window.BBR_supabase.rpc("community_post_add", args));
      if (!out || out.error || !out.data) throw (out && out.error) || new Error("failed");
      // Cancelled while the post was being written: it went out, so it is
      // shown (the reader can delete it), but the box is theirs now.
      if (over()) { onPosted && onPosted(out.data); return; }
      window.BBR_track && window.BBR_track("community_post", { prompt: prompt || "none", photos: media.filter((m) => m.kind === "image").length, video: media.some((m) => m.kind === "video") ? 1 : 0, record: rec ? rec.kind : "none", where: where || "feed" });
      reset();
      setBusy(false);
      setSaid("Posted.");
      setTimeout(() => { if (fakeRef.current) fakeRef.current.focus(); }, 0);
      onPosted && onPosted(out.data);
    } catch (e) {
      if (over()) return;
      setBusy(false); setStage("");
      setErr(String((e && e.message) || "") === "timeout" ? "The upload is taking too long. Check your connection and try again." : cmtyPostErr(e));
    }
  };

  const as = cmtyMeLabel(meId);
  return (
    <section className={"cmty-cmp" + (open ? " open" : "")} aria-label="Write a post">
      <div className="cmty-cmp-top">
        {meId ? <span className="cmty-cmp-me" title={as ? "Posting as " + as : undefined}><CmtyAvatar actor={meActor || { user_id: meId }} size={40} /></span> : null}
        {!open ? (
          <button className="cmty-cmp-fake" ref={fakeRef} onClick={() => begin()} disabled={authReady === false}>
            {meId ? "Share something with other collectors…" : "Sign in to post to the community…"}
          </button>
        ) : (
          tag.field(
            <textarea ref={taRef} className="cmty-cmp-ta" value={text} maxLength={CMTY_POST_MAX} rows={4}
              placeholder={pr ? pr.ask : "What’s on your mind? A record, a find, a question… Type @ to tag a collector."}
              aria-label={"Your post" + (as ? ", posting as " + as : "")}
              onChange={(e) => { setText(e.target.value); if (err) setErr(""); }}
              onKeyDown={(e) => { if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) { e.preventDefault(); submit(); } }} />
          )
        )}
      </div>
      <div className="cmty-cmp-prompts" role="group" aria-label="Ideas for a post">
        {CMTY_PROMPTS.map((p) => (
          <button key={p.k} className={"cmty-chip" + (prompt === p.k ? " on" : "")} aria-pressed={prompt === p.k} onClick={() => begin(p.k)} title={p.ask}>{p.label}</button>
        ))}
      </div>
      {open ? (
        <React.Fragment>
          {files.length ? (
            <ul className="cmty-cmp-files" aria-label="Attached">
              {files.map((f) => (
                <li key={f.id}>
                  {f.kind === "video" ? <video src={f.url} muted playsInline preload="metadata" /> : <img src={f.url} alt="" />}
                  {f.kind === "video" ? <span className="cmty-cmp-dur">{Math.round(f.dur)}s</span> : null}
                  <button className="cmty-cmp-x" aria-label={f.kind === "video" ? "Remove the video" : "Remove photo " + (files.indexOf(f) + 1) + " of " + files.length} disabled={busy}
                    onClick={() => { dropFile(f.id); setTimeout(() => { if (taRef.current) taRef.current.focus(); }, 0); }}>×</button>
                </li>
              ))}
            </ul>
          ) : null}
          {rec ? (
            <div className="cmty-cmp-rec">
              <CmtySleeve item={rec} size={40} />
              <span><b>{rec.title}</b> <span className="cmty-meta">{rec.artist}{rec.kind === "dive" ? " · Deep dive" : ""}</span></span>
              <button className="cmty-cmp-x" aria-label="Remove the record" disabled={busy} onClick={() => setRec(null)}>×</button>
            </div>
          ) : null}
          {recOpen && !rec ? (
            <div className="cmty-cmp-find">
              <input className="cmty-avp-q" type="search" value={q} autoFocus onChange={(e) => setQ(e.target.value)}
                placeholder={(mine && mine.length ? "Search your shelf or the deep dives" : "Search the deep dives")} aria-label="Find a record" />
              {recMatches.map((r) => (
                <button key={r.kind + (r.id || r.slug)} className="cmty-avp-row" onClick={() => { setRec(r); setRecOpen(false); setQ(""); }}>
                  <CmtySleeve item={r} size={32} />
                  <span><b>{r.title}</b> <span className="cmty-meta">{r.artist}{r.kind === "dive" ? " · Deep dive" : " · On your shelf"}</span></span>
                </button>
              ))}
              {q.trim().length >= 2 && !recMatches.length ? <p className="cmty-meta">Nothing matches that.</p> : null}
            </div>
          ) : null}
          {err ? <p className="cmty-say-err" role="alert">{err}</p> : null}
          <div className="cmty-cmp-bar">
            <span className="cmty-cmp-tools">
              <button type="button" className="cmty-cmp-tool" disabled={busy || picking || hasVideo || nPhotos >= 4} onClick={() => photoRef.current && photoRef.current.click()}>
                <svg viewBox="0 0 20 20" width="16" height="16" aria-hidden="true"><rect x="2.5" y="4" width="15" height="12" rx="2" fill="none" stroke="currentColor" strokeWidth="1.5" /><circle cx="7.5" cy="8.5" r="1.6" fill="currentColor" /><path d="M3 14l4.5-4 3.5 3 2.5-2 3.5 3" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinejoin="round" /></svg>
                Photo
              </button>
              <button type="button" className="cmty-cmp-tool" disabled={busy || picking || files.length > 0} onClick={() => videoRef.current && videoRef.current.click()}>
                <svg viewBox="0 0 20 20" width="16" height="16" aria-hidden="true"><rect x="2.5" y="5" width="11" height="10" rx="2" fill="none" stroke="currentColor" strokeWidth="1.5" /><path d="M13.5 9l4-2.5v7l-4-2.5z" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinejoin="round" /></svg>
                Video
              </button>
              <button type="button" className="cmty-cmp-tool" aria-expanded={recOpen} disabled={busy || !!rec} onClick={() => setRecOpen(!recOpen)}>
                <svg viewBox="0 0 20 20" width="16" height="16" aria-hidden="true"><circle cx="10" cy="10" r="7.5" fill="none" stroke="currentColor" strokeWidth="1.5" /><circle cx="10" cy="10" r="2" fill="currentColor" /></svg>
                Record
              </button>
              <input ref={photoRef} type="file" accept="image/*" multiple hidden onChange={(e) => { addPhotos(e.target.files); e.target.value = ""; }} />
              <input ref={videoRef} type="file" accept="video/mp4,video/quicktime,video/webm,video/*" hidden onChange={(e) => { addVideo(e.target.files && e.target.files[0]); e.target.value = ""; }} />
            </span>
            <span className="cmty-cmp-go">
              <span className="cmty-meta" aria-live="polite">{picking ? "Reading…" : text.length > CMTY_POST_MAX - 200 ? (CMTY_POST_MAX - text.length) + " left" : ""}</span>
              {/* Not in one tap when there is something to lose (UX review, 14 Oct 2026). */}
              {discard ? (
                <CmtyAsk q="Discard this post?" yes="Discard" no="Keep writing" noRef={keepRef}
                  onYes={() => { setDiscard(false); reset(); setBusy(false); setTimeout(() => { if (fakeRef.current) fakeRef.current.focus(); }, 0); }}
                  onNo={() => { setDiscard(false); setTimeout(() => { if (taRef.current) taRef.current.focus(); }, 0); }} />
              ) : (
                <button type="button" className="cmty-link" onClick={() => {
                  if (!busy && (text.trim() || files.length || rec)) { setDiscard(true); setTimeout(() => { if (keepRef.current) keepRef.current.focus(); }, 0); return; }
                  reset(); setBusy(false); setTimeout(() => { if (fakeRef.current) fakeRef.current.focus(); }, 0);
                }}>Cancel</button>
              )}
              <button type="button" className="cmty-follow on" disabled={!canPost} onClick={submit}>{busy ? (stage || "Posting…") : "Post"}</button>
            </span>
          </div>
        </React.Fragment>
      ) : null}
      <p className="cmty-vh" role="status">{said}</p>
    </section>
  );
}

// One post of any kind.
function CmtyPostOf(props) {
  const k = props.ev && props.ev.kind;
  if (k === "adds") return <CmtyAddsPost {...props} />;
  if (k === "tier") return <CmtyTierPost {...props} />;
  if (k === "dive") return <CmtyDivePost {...props} />;
  if (k === "post") return <CmtyUserPost {...props} />;
  return null;
}

function CmtyTierPost({ ev, meId, soc, onViewCollection, onTalk, focus }) {
  const items = ev.items || [];
  const talk = useCmtyTalk(ev, meId, soc, onViewCollection, onTalk, focus,
    items.length === 1 ? "the market move for " + (items[0].title || "a pressing") : "these market moves");
  // One pressing is one line, not a card with a headline over a list of one:
  // three quiet market days in a row were pushing every collector off the screen.
  if (items.length === 1) {
    const it = items[0];
    return (
      <article className="cmty-post slim">
        <CmtySleeve item={it} size={56} />
        <div className="cmty-move-t">
          <div className="cmty-meta">Market move · held for three days</div>
          <div><b>{it.title}</b> <span className="cmty-meta">{it.artist}</span></div>
          <CmtyOwners owners={it.owners} count={it.owner_count} meId={meId} />
        </div>
        <div className="cmty-slim-end">
          <CmtyTierMove it={it} />
          <span className="cmty-acts-l">
            <CmtyLike soc={soc} target={ev.key} base={ev} what={"this market move, " + it.title} />
            {talk.button}
          </span>
        </div>
        {talk.thread}
      </article>
    );
  }
  return (
    <article className="cmty-post">
      <header className="cmty-who">
        <span className="cmty-av sys" aria-hidden="true">&#8599;</span>
        <div className="cmty-line">
          <b>{cmtyPlural(items.length, "pressing")}</b> moved up a rarity tier
          <div className="cmty-meta">Market move · held for three days</div>
        </div>
      </header>
      <ul className="cmty-moves">
        {items.slice(0, 5).map((it, i) => (
          <li key={i}>
            <CmtySleeve item={it} size={48} />
            <div className="cmty-move-t">
              <div><b>{it.title}</b> <span className="cmty-meta">{it.artist}</span></div>
              <CmtyOwners owners={it.owners} count={it.owner_count} meId={meId} />
            </div>
            <CmtyTierMove it={it} />
          </li>
        ))}
      </ul>
      {items.length > 5 ? <div className="cmty-meta cmty-moves-more">and {items.length - 5} more</div> : null}
      <footer className="cmty-acts"><span className="cmty-acts-l"><CmtyLike soc={soc} target={ev.key} base={ev} what="these market moves" />{talk.button}</span></footer>
      {talk.thread}
    </article>
  );
}

function CmtyDivePost({ ev, meId, soc, onViewCollection, onTalk, focus }) {
  const r = ev.record || {};
  const talk = useCmtyTalk(ev, meId, soc, onViewCollection, onTalk, focus, "the deep dive on " + (r.title || "this record"));
  const go = () => window.__nav && window.__nav.goDeepDive(r.slug);
  // "New" for a week. The feed runs back months, and a forty-day-old dive is not.
  const isNew = Date.now() - new Date(ev.at).getTime() < 7 * 86400000;
  return (
    <article className="cmty-post">
      <header className="cmty-who">
        <span className="cmty-av sys" aria-hidden="true">&sect;</span>
        <div className="cmty-line">
          <b>{isNew ? "New deep dive" : "Deep dive"}: {r.title}</b>
          <div className="cmty-meta">{r.artist}{r.year ? " · " + r.year : ""}</div>
        </div>
      </header>
      <div className="cmty-one big">
        <CmtySleeve item={r} size={132} />
        <div>
          {ev.excerpt ? <p className="cmty-excerpt">{ev.excerpt}</p> : null}
          <a className="cmty-link" href={"/deep-dive/" + encodeURIComponent(r.slug)} onClick={(e) => { if (e.metaKey || e.ctrlKey || e.shiftKey || e.button !== 0) return; e.preventDefault(); go(); }}>Read the deep dive</a>
        </div>
      </div>
      <CmtyOwners owners={ev.owners} count={ev.owner_count} meId={meId} lone="Already on" slug={r.slug} title={r.title} onViewCollection={onViewCollection} />
      <footer className="cmty-acts"><span className="cmty-acts-l"><CmtyLike soc={soc} target={ev.key} base={ev} what={"the deep dive on " + r.title} />{talk.button}</span></footer>
      {talk.thread}
    </article>
  );
}

function CmtyCount({ value }) {
  const n = Math.round(Number(value) || 0);
  return window.HdCountUp ? <window.HdCountUp value={n} /> : <React.Fragment>{n.toLocaleString("en-GB")}</React.Fragment>;
}
// FRESH ON THE SHELVES. The newest records across every shelf as one moving
// row of sleeves, drawn from the adds already loaded for the feed (no second
// read). The row is doubled so the scroll loops without a seam; it pauses under
// a pointer or a held finger, and under prefers-reduced-motion it does not move
// at all and scrolls by hand instead. The tiles are out of the tab order: they
// were 18 tab stops between the tabs and the filters, and every record on the
// wall is in the feed below with its own "View shelf".
function CmtyWall({ events, onViewCollection }) {
  const tiles = useCmMemo(() => {
    const out = [], seen = new Set();
    for (const ev of events || []) {
      if (ev.kind !== "adds") continue;
      for (const it of ev.items || []) {
        const k = (it.artist || "") + "|" + (it.title || "");
        if (seen.has(k)) continue;
        seen.add(k);
        out.push({ it, actor: ev.actor });
        if (out.length >= 18) return out;
      }
    }
    return out;
  }, [events]);
  if (tiles.length < 8) return null;
  const row = (dup) => tiles.map((t, i) => (
    <button className="cmty-wall-t" key={(dup ? "d" : "") + i} tabIndex={-1} aria-hidden={dup ? "true" : undefined}
      title={t.it.title + " · " + t.it.artist + " · on " + cmtyName(t.actor, null) + "’s shelf"}
      onClick={() => onViewCollection(t.actor.user_id, cmtyName(t.actor, null))}>
      <CmtySleeve item={t.it} size={104} />
      <span className="cmty-wall-c"><b>{t.it.artist}</b>{cmtyName(t.actor, null)}</span>
    </button>
  ));
  return (
    <section className="cmty-wall" aria-label="Fresh on the shelves">
      <h2 className="cmty-wall-h"><span className="cmty-pulse" aria-hidden="true" /> Fresh on the shelves</h2>
      <div className="cmty-wall-view"><div className="cmty-wall-track" style={{ "--n": tiles.length }}>{row(false)}{row(true)}</div></div>
    </section>
  );
}

// "following" is answered by the server (p_following); the rest filter what is loaded.
const CMTY_FILTERS = [["all", "Everything"], ["following", "Following"], ["post", "Posts"], ["adds", "New records"], ["tier", "Market"], ["dive", "Deep dives"]];

// THE FEED SURVIVES LEAVING IT. Opening a shelf, or the Collectors tab, unmounts
// this page; coming back used to mean a fresh first page at the top, the
// reader's place, filter and every "earlier activity" page gone, and the
// heaviest read on the site run again. What is loaded is kept here and put back
// when the page is re-entered within ten minutes by the same reader. The scroll
// position is put back too, but only for Back and Forward: a click on a tab or
// the nav means "take me there", which is the top.
const cmtyFeedKeep = { meId: null, filter: "all", events: null, wall: null, more: false, stats: null, y: 0, at: 0 };
let cmtyPoppedAt = 0;
window.addEventListener("popstate", () => { cmtyPoppedAt = Date.now(); });
function cmtyFeedRestorable(meId) {
  return !!cmtyFeedKeep.events && cmtyFeedKeep.meId === (meId || null) && Date.now() - cmtyFeedKeep.at < 10 * 60 * 1000;
}

function CmtyFeed({ meId, authReady, soc, onViewCollection, onRequireAuth, onStats }) {
  const restored = useCmRef(cmtyFeedRestorable(meId));
  const keep = restored.current ? cmtyFeedKeep : null;
  const [events, setEvents] = useCmState(keep ? keep.events : null);
  // The wall shows everyone's newest records whatever the filter says, so it is
  // fed from the last unfiltered page and does not blink out under the pointer
  // (and come back as "Fresh on the shelves" of only the people you follow).
  const [wall, setWall] = useCmState(keep ? keep.wall : null);
  const [more, setMore] = useCmState(keep ? keep.more : false);
  const [stats, setStats] = useCmState(keep ? keep.stats : null);
  const [err, setErr] = useCmState(false);
  const [busy, setBusy] = useCmState(false);
  const [swapping, setSwapping] = useCmState(false);
  const [filter, setFilter] = useCmState(keep ? keep.filter : "all");
  const [fresh, setFresh] = useCmState(false);
  // Auth has answered, or two and a half seconds have passed without it. The
  // first read waits for that so a signed-in page load reads the feed ONCE, as
  // the member; it used to read it signed-out and then again a moment later.
  const [waited, setWaited] = useCmState(authReady !== false);
  useCmEffect(() => {
    if (waited) return;
    if (authReady !== false) { setWaited(true); return; }
    const t = setTimeout(() => setWaited(true), 2500);
    return () => clearTimeout(t);
  }, [authReady, waited]);

  // ONE POST, BROUGHT TO THE TOP: from a line in the inbox, or a link in an
  // email (/community?post=<key>). It may be far down the feed or off the first
  // page, so it is read on its own (community_post()) and shown with its
  // comments open. The key is taken out of the address once read, or it would
  // ride along onto the next page the reader opens.
  const [focus, setFocus] = useCmState(() => {
    try {
      const sp = new URLSearchParams(location.search), k = sp.get("post");
      if (!k) return null;
      sp.delete("post");
      history.replaceState(history.state, "", location.pathname + (sp.toString() ? "?" + sp.toString() : "") + location.hash);
      return { key: k.slice(0, 120), ev: null, state: "wait" };
    } catch (e) { return null; }
  });
  const focusRef = useCmRef(null);
  // Scroll to it and put focus on its heading, so a keyboard or screen-reader
  // user lands where the page has just moved to.
  const toFocus = () => setTimeout(() => {
    const el = focusRef.current;
    if (!el) return;
    if (el.scrollIntoView) el.scrollIntoView({ block: "start", behavior: "smooth" });
    const h = el.querySelector("h2");
    if (h) h.focus({ preventScroll: true });
  }, 60);
  // A second line in the inbox about the post that is already open must not
  // throw the conversation (and a half-written reply) away: asking for the
  // same post again only goes back to it. `n` asks for a fresh read when the
  // last one failed.
  // `cid`: the comment a notification is about, shown and lit once the
  // thread is open (14 Oct 2026, Ric: it took you to the post, not the comment).
  // From For you (`step`), the post is a step in history: Back closes it and
  // returns to the line that was tapped, rather than leaving Community (UX
  // review, 14 Oct 2026). A second post opened from there replaces the step.
  const openPost = (key, cid, step) => {
    if (step) {
      const st = Object.assign({}, history.state || {}, { bbrCmtyPost: key });
      if (history.state && history.state.bbrCmtyPost) history.replaceState(st, "", location.pathname + location.search + location.hash);
      else history.pushState(st, "", location.pathname + location.search + location.hash);
    }
    setFocus((f) => (f && f.key === key && f.state !== "gone" && f.state !== "err"
      ? Object.assign({}, f, { cid: cid || null, hit: (f.hit || 0) + 1, step: !!step || f.step })
      : { key, cid: cid || null, hit: 1, ev: null, state: "wait", n: ((f && f.n) || 0) + 1, step: !!step }));
    if (!cid) toFocus();
  };
  const closePost = () => {
    // The step is ours: go back over it (the listener below closes the post).
    if (history.state && history.state.bbrCmtyPost) { history.back(); return; }
    setFocus(null);
    setTimeout(cmtyInboxReturn, 0);
  };
  const stepOpen = useCmRef(false);
  stepOpen.current = !!(focus && focus.step);
  useCmEffect(() => {
    const onPop = () => {
      // Only the step For you made: a dialog closing pops its own entry too.
      if (!stepOpen.current || (history.state && history.state.bbrCmtyPost)) return;
      stepOpen.current = false;
      setFocus(null);
      setTimeout(cmtyInboxReturn, 0);
    };
    window.addEventListener("popstate", onPop);
    return () => window.removeEventListener("popstate", onPop);
  }, []);
  const focusCid = focus && focus.state === "ok" ? focus.cid : null, focusHit = focus ? focus.hit : 0;
  useCmEffect(() => {
    if (!focusCid) return undefined;
    // The thread reads its comments after the post is shown: look for the
    // comment for a few seconds, then fall back to the top of the post.
    let tries = 0, done = false;
    const t = setInterval(() => {
      const box = focusRef.current;
      const el = box && box.querySelector('[data-cid="' + focusCid + '"]');
      if (el) {
        clearInterval(t); done = true;
        try { el.scrollIntoView({ block: "center", behavior: "smooth" }); } catch (e) { el.scrollIntoView(); }
        el.classList.remove("cmty-c-hit"); void el.offsetWidth; el.classList.add("cmty-c-hit");
        el.focus({ preventScroll: true });
      } else if (++tries > 30) { clearInterval(t); done = true; toFocus(); }
    }, 150);
    return () => { if (!done) clearInterval(t); };
  }, [focusCid, focusHit]);
  const focusKey = focus ? focus.key : null, focusN = focus ? (focus.n || 0) : 0;
  useCmEffect(() => {
    // `waited`, not authReady: if the session never answers, the post is read
    // signed-out after two and a half seconds, as the feed is.
    if (!focusKey || !waited) return;
    if (!window.BBR_supabase) { setFocus((f) => (f && f.key === focusKey ? Object.assign({}, f, { state: "err" }) : f)); return; }
    let live = true;
    Promise.resolve(window.BBR_supabase.rpc("community_post", { p_key: focusKey })).then((out) => {
      if (!live) return;
      // A refusal or a dropped connection is not "that post is gone".
      if (!out || out.error) { setFocus((f) => (f && f.key === focusKey ? Object.assign({}, f, { ev: null, state: "err" }) : f)); return; }
      const ev = out.data || null;
      setFocus((f) => (f && f.key === focusKey ? Object.assign({}, f, { ev, state: ev ? "ok" : "gone" }) : f));
      toFocus();
    }, () => { if (live) setFocus((f) => (f && f.key === focusKey ? Object.assign({}, f, { ev: null, state: "err" }) : f)); });
    return () => { live = false; };
  }, [focusKey, focusN, meId || null, waited]);
  // Signed in from the line under a thread: bring that post back to the top,
  // because the feed is about to be read again as this reader and a post from
  // beyond the first page would go with it (see cmtyTalkWant).
  useCmEffect(() => {
    if (!meId || !cmtyTalkWant.key) return;
    const k = cmtyTalkWant.key, fresh = Date.now() - cmtyTalkWant.at < 3 * 60 * 1000;
    cmtyTalkWant.key = null;
    if (fresh) openPost(k);
  }, [meId || null]);
  // A comment posted or removed changes the post it sits on, wherever that
  // post is being shown: in the list, and in the one brought to the top.
  const onTalk = (key, patch) => {
    setEvents((es) => (es ? es.map((e) => (e.key === key ? Object.assign({}, e, patch) : e)) : es));
    setFocus((f) => (f && f.ev && f.key === key ? Object.assign({}, f, { ev: Object.assign({}, f.ev, patch) }) : f));
  };
  // A post deleted or reported here stays on screen as its one-line notice and
  // is left out of what is kept for the way back.
  const goneKeys = useCmRef({});
  const onGone = (key) => { goneKeys.current[key] = true; };
  const postOf = (ev, isFocus) => <CmtyPostOf ev={ev} meId={meId} soc={soc} onViewCollection={onViewCollection} onTalk={onTalk} focus={isFocus} onGone={onGone} />;
  // A post just written goes to the top at once, whatever the filter (it is
  // the writer's own); the next read brings it in its place.
  const onPosted = (ev) => {
    if (!ev || !ev.key) return;
    setEvents((es) => [ev].concat((es || []).filter((e) => e.key !== ev.key)));
    if (filter !== "all" && filter !== "post" && filter !== "following") setFilter("all");
  };
  const following = filter === "following";
  // Which reader and filter the posts on screen belong to.
  const shownFor = useCmRef(keep ? { meId: keep.meId, following: keep.filter === "following" } : null);
  // ONLY THE NEWEST REQUEST MAY WRITE. Switching Following on and off quickly, or
  // signing in while the first read was in flight, left two requests racing, and
  // whichever answered LAST won: the page showed the Following feed under
  // "Everything", or lost the reader's likes to the signed-out answer. Every
  // load takes a number; an answer that is no longer the newest is dropped,
  // including a "show earlier" page for a feed that has since changed.
  const reqRef = useCmRef(0);
  const load = (after) => {
    const id = ++reqRef.current;
    const top = !after;
    setBusy(true); setErr(false);
    if (top) setSwapping(true);
    return Promise.resolve(window.BBR_supabase.rpc("community_feed", {
      p_limit: CMTY_PAGE, p_following: following,
      // The key breaks a tie: two posts with the same timestamp on a page edge
      // used to lose one.
      p_before: after ? after.at : null, p_before_key: after ? after.key : null,
    }))
      .then((out) => {
        if (id !== reqRef.current) return;
        if (!out || out.error || !out.data) throw (out && out.error) || new Error("no data");
        const rows = out.data.events || [];
        setEvents((prev) => (!top && prev ? prev.concat(rows) : rows));
        setMore(!!out.data.more);
        if (top) {
          shownFor.current = { meId: meId || null, following };
          if (!following) setWall(rows);
          if (out.data.stats) { setStats(out.data.stats); onStats && onStats(out.data.stats); }
        }
      })
      .catch((e) => {
        if (id !== reqRef.current) return;
        console.error("[community] feed", e);
        setErr(true);
        // A failed reload keeps what was on screen, unless what is on screen
        // belongs to a different filter or reader: those posts would be a lie
        // under the chip that is now lit.
        const s = shownFor.current;
        if (top && s && (s.following !== following || s.meId !== (meId || null))) { setEvents(null); shownFor.current = null; }
      })
      .then(() => { if (id === reqRef.current) { setBusy(false); setSwapping(false); } });
  };
  // Reload from the top when the reader changes (their likes and follows ride
  // on the rows) or when Following is switched on or off. Not on the first pass
  // of a restored feed: that one already has its posts.
  useCmEffect(() => {
    if (!waited) return;
    if (restored.current) {
      // Only if the kept posts really are this reader's, under this filter. The
      // flag must not swallow the reload that a reader arriving later needs.
      restored.current = false;
      const s = shownFor.current;
      if (s && s.meId === (meId || null) && s.following === following) return;
    }
    setFresh(false);
    if (window.BBR_supabase) load(null); else setErr(true);
  }, [meId, following, waited]);
  // Keep what is loaded, and where the reader is, for the way back.
  useCmEffect(() => {
    Object.assign(cmtyFeedKeep, { meId: meId || null, filter, events: events ? events.filter((e) => !goneKeys.current[e.key]) : events, wall, more, stats, at: Date.now() });
  }, [meId, filter, events, wall, more, stats]);
  // LIVE. Ask for the newest post's key; if it is not the one at the top of
  // this feed, offer it. Never splice posts in under the reader's thumb.
  const topKey = events && events[0] ? events[0].key : null;
  const check = (isLive) => {
    if (document.hidden || !window.BBR_supabase) return;
    Promise.resolve(window.BBR_supabase.rpc("community_feed", { p_limit: 1, p_before: null, p_following: following })).then((out) => {
      const k = out && out.data && out.data.events && out.data.events[0] && out.data.events[0].key;
      if (isLive() && k && k !== topKey) setFresh(true);
    }, () => {});
  };
  useCmEffect(() => {
    if (keep && keep.stats && onStats) onStats(keep.stats);
    // Back and Forward return to where the reader was; a restored feed may be
    // minutes old, so it is checked for news straight away.
    if (keep && Date.now() - cmtyPoppedAt < 1500) requestAnimationFrame(() => window.scrollTo(0, cmtyFeedKeep.y));
    let live = true;
    if (keep) check(() => live);
    let raf = 0;
    // Not while a dialog holds the page still (the body is fixed and the
    // window reads 0): that was kept as the reader's place.
    const onScroll = () => { if (!raf) raf = requestAnimationFrame(() => { raf = 0; if (document.body.style.position !== "fixed") cmtyFeedKeep.y = window.scrollY || 0; }); };
    window.addEventListener("scroll", onScroll, { passive: true });
    return () => { live = false; window.removeEventListener("scroll", onScroll); if (raf) cancelAnimationFrame(raf); reqRef.current = -1; };
  }, []);
  useCmEffect(() => {
    if (!topKey) return;
    // `live` goes false when the filter or the top post changes, so an answer
    // already on its way cannot light the pill for a feed that has moved on.
    let live = true;
    const t = setInterval(() => check(() => live), CMTY_POLL_MS);
    return () => { live = false; clearInterval(t); };
  }, [topKey, following]);
  const pick = (k) => {
    if (k === "following" && !meId) { soc.gate(); return; }
    setFilter(k);
    window.BBR_track && window.BBR_track("community_filter", { filter: k });
  };

  const shown = useCmMemo(() => (events || []).filter((e) => filter === "all" || following || e.kind === filter), [events, filter]);

  return (
    <div className="cmty-cols">
      <div className="cmty-feed">
        {/* First, for a signed-in reader: on a phone it sat under the figures
            and the wall of sleeves, two screens down (Ric, 14 Oct 2026). */}
        <CmtyInboxPanel meId={meId} onViewCollection={onViewCollection} onOpenPost={openPost} />
        {/* A member's post box comes next, not under the site's figures and the
            wall of sleeves, a screen and a half down on a phone (UX review). */}
        {meId ? <CmtyComposer meId={meId} authReady={authReady} onPosted={onPosted} where="feed" /> : null}
        {stats ? (
          <div className="lb-stats cmty-stats">
            <div className="lb-stat lb-stat-hero">
              <div className="lb-stat-n"><span className="cur">£</span><CmtyCount value={stats.total_value} /></div>
              <div className="lb-stat-l">Across all shelves</div>
            </div>
            <div className="lb-stat"><div className="lb-stat-n"><CmtyCount value={stats.records} /></div><div className="lb-stat-l">Records logged</div></div>
            <div className="lb-stat"><div className="lb-stat-n"><CmtyCount value={stats.added_7d} /></div><div className="lb-stat-l">Added this week</div></div>
            <div className="lb-stat"><div className="lb-stat-n"><CmtyCount value={stats.tier_moves_7d} /></div><div className="lb-stat-l">Up a tier this week</div></div>
            <div className="lb-stat"><div className="lb-stat-n"><CmtyCount value={stats.collectors} /></div><div className="lb-stat-l">Collectors</div></div>
          </div>
        ) : null}
        <CmtyWall events={wall || (following ? null : events)} onViewCollection={onViewCollection} />
        {meId ? null : <CmtyComposer meId={meId} authReady={authReady} onPosted={onPosted} where="feed" />}
        {focus ? (
          <section className="cmty-focus" ref={focusRef} aria-label="The post you opened">
            <div className="cmty-focus-h">
              <h2 className="cmty-day" tabIndex={-1}>The conversation</h2>
              <button className="cmty-link" onClick={closePost}>Back to all activity</button>
            </div>
            {focus.state === "wait" ? <p className="cmty-empty">Finding that post…</p> : null}
            {focus.state === "gone" ? <p className="cmty-empty">That post isn’t in the feed any more.</p> : null}
            {focus.state === "err" ? <p className="cmty-empty">That post didn’t load. <button className="cmty-link" onClick={() => setFocus((f) => (f ? { key: f.key, ev: null, state: "wait", n: (f.n || 0) + 1 } : f))}>Try again</button></p> : null}
            {focus.ev ? <div key={focus.key}>{postOf(focus.ev, true)}</div> : null}
          </section>
        ) : null}
        <div className="cmty-chips" role="group" aria-label="Show">
          {CMTY_FILTERS.map(([k, label]) => (
            <button key={k} className={"cmty-chip" + (filter === k ? " on" : "")} aria-pressed={filter === k}
              onClick={() => pick(k)}>{label}</button>
          ))}
        </div>
        {err && !events ? <p className="cmty-empty">{CMTY_LOAD_ERR}</p> : null}
        {!events && !err ? <p className="cmty-empty">Loading the feed…</p> : null}
        {fresh ? (
          // The posts stay where they are until the new ones arrive. This used to
          // empty the feed first, so a failed request took the page with it.
          <button className="cmty-fresh" onClick={() => { setFresh(false); load(null); window.scrollTo({ top: 0, behavior: "smooth" }); }}>
            <span className="cmty-pulse" aria-hidden="true" /> New activity. Show it now
          </button>
        ) : null}
        {err && events ? <p className="cmty-empty">{CMTY_LOAD_ERR}</p> : null}
        <div className={"cmty-list" + (swapping && events ? " is-swapping" : "")} aria-busy={swapping ? "true" : undefined}>
          {events && !shown.length && !swapping ? (
            <p className="cmty-empty">
              {following ? "Nothing yet from the collectors you follow. Follow a few and their posts and new records land here. " : "Nothing like that in the latest activity. Choose Everything, or come back later."}
              {following && window.__nav ? <button className="cmty-link" onClick={() => window.__nav.goCollectors()}>Find collectors</button> : null}
            </p>
          ) : null}
          {shown.map((ev, i) => {
            const head = i === 0 || cmtyDayKey(shown[i - 1].at) !== cmtyDayKey(ev.at);
            return (
              <React.Fragment key={ev.key}>
                {head ? <h2 className="cmty-day">{cmtyDayLabel(ev.at)}</h2> : null}
                <div className="cmty-in" style={{ "--i": Math.min(i, 8) }}>
                {postOf(ev, false)}
                </div>
              </React.Fragment>
            );
          })}
        </div>
        {events && more ? (
          <button className="cmty-loadmore" disabled={busy}
            onClick={() => { load(events[events.length - 1]); window.BBR_track && window.BBR_track("community_more", {}); }}>
            {busy ? "Loading…" : "Show earlier activity"}
          </button>
        ) : null}
      </div>
    </div>
  );
}

// ---- the tile's one-line description ---------------------------------------
// Written from what the shelf holds (its genres, decades and rarity tiers), all
// of which the public item view already shows. It is NOT the owner's Collection
// DNA read: that is addressed to them ("Your collection…") and is theirs alone.
const CMTY_GENRE_WORDS = {
  "Rock": "rock", "Electronic": "electronic", "Pop": "pop", "Jazz": "jazz", "Soul / R&B": "soul and R&B",
  "Hip-Hop": "hip-hop", "Folk / Country": "folk and country", "Stage & Screen": "soundtracks",
  "Classical": "classical", "Reggae": "reggae", "Reggae / Dub": "reggae and dub", "Children's": "children’s records",
  "Blues": "blues", "Metal": "metal", "Punk / Indie": "punk and indie", "Latin": "Latin",
  "Krautrock": "krautrock", "Ambient": "ambient", "Brass & Military": "brass and military bands",
};
function cmtyGenreWord(g) { return CMTY_GENRE_WORDS[g] || String(g || "").toLowerCase().replace(" / ", " and ").replace(" & ", " and "); }
function cmtyDescribe(c) {
  const count = Number(c.item_count) || 0;
  // Shares are of the WHOLE shelf, classified or not: ten rock records among a
  // hundred with no genre is "some rock", and used to read "Mostly rock".
  const gs = (c.genres || []).map((g) => ({ w: cmtyGenreWord(g.genre), s: g.n / (count || 1) }));
  let first = "";
  if (gs.length) {
    if (gs[0].s >= 0.7) first = "Almost all " + gs[0].w;
    else if (gs[0].s >= 0.45) first = "Mostly " + gs[0].w + (gs[1] && gs[1].s >= 0.15 ? ", with a strong line in " + gs[1].w : "");
    else if (gs.length >= 3 && gs[2].s >= 0.1) first = "A mix of " + gs[0].w + ", " + gs[1].w + " and " + gs[2].w;
    else if (gs.length >= 2 && gs[1].s >= 0.1) first = "A mix of " + gs[0].w + " and " + gs[1].w;
    else first = "Some " + gs[0].w;
  }
  const ds = c.decades || [], dated = Number(c.dated) || 0;
  let when = "";
  if (ds.length && dated >= 5) {
    const s0 = ds[0].n / dated, s1 = ds[1] ? ds[1].n / dated : 0;
    if (ds[1] && s0 >= 0.25 && s1 >= 0.25 && Math.abs(ds[0].decade - ds[1].decade) === 10) {
      const lo = Math.min(ds[0].decade, ds[1].decade);
      when = "centred on the " + lo + "s and " + (lo + 10) + "s";
    } else if (s0 >= 0.35) when = "weighted to the " + ds[0].decade + "s";
  }
  let out = first ? first + (when ? ", " + when : "") + "." : (when ? "A shelf " + when + "." : "");
  const t = c.tiers || {};
  if (t.grail > 0) out += " " + cmtyPlural(t.grail, "Grail pressing") + " on the shelf.";
  else if (t.rare > 0) out += " " + cmtyPlural(t.rare, "Rare pressing") + " on the shelf.";
  // A size, not a history. "Just getting started" said when the shelf began,
  // which is the one thing this page may not say of an anonymous collector, and
  // was wrong for an old small shelf anyway.
  else if (count < 10) out += " A small shelf.";
  return out.trim() || "A shelf in the making.";
}
// The shelf's genre mix as one bar: its top three genres in their own colours,
// the remainder in grey. Widths are shares of the whole shelf.
function CmtyGenreBar({ c }) {
  const count = Number(c.item_count) || 0, gs = c.genres || [];
  if (!count || !gs.length) return null;
  const used = gs.reduce((n, g) => n + g.n, 0);
  return (
    <div className="cmty-mix">
      <div className="cmty-mix-bar" role="img" aria-label={"Genre mix: " + gs.map((g) => g.genre + " " + Math.round(100 * g.n / count) + "%").join(", ")}>
        {gs.map((g) => <span key={g.genre} style={{ width: (100 * g.n / count) + "%", background: cmtyGenreHue(g.genre) }} />)}
        {used < count ? <span style={{ width: (100 * (count - used) / count) + "%", background: "var(--rule)" }} /> : null}
      </div>
      <div className="cmty-mix-key" aria-hidden="true">
        {gs.slice(0, 2).map((g) => <span key={g.genre}><i style={{ background: cmtyGenreHue(g.genre) }} />{g.genre} {Math.round(100 * g.n / count)}%</span>)}
      </div>
    </div>
  );
}
// Scarce first: a badge nobody lacks is not a badge. Four at most.
function cmtyBadges(c) {
  const out = [];
  if (c.rank_value === 1) out.push({ label: "Top shelf", hot: true, title: "The most valuable shelf on The Lead-In" });
  if (c.rank_count === 1) out.push({ label: "Biggest shelf", hot: true, title: "The most records on The Lead-In" });
  const t = c.tiers || {};
  if (t.grail > 0) out.push({ label: cmtyPlural(t.grail, "Grail"), title: "Grail pressings: about £400 or more at Near Mint, and hard to find" });
  const leads = c.leads || [];
  if (leads.length) out.push({ label: leads[0] + " leader" + (leads.length > 1 ? " +" + (leads.length - 1) : ""), title: "Holds the most valuable " + leads.join(", ") + " shelf" });
  if (c.is_public && c.member_since && String(c.member_since).slice(0, 10) < CMTY_FOUNDING_BEFORE) out.push({ label: "Founding member", title: "Here since the first month" });
  const level = cmtyLevel(c.item_count);
  if (level && Number(c.item_count) >= 10) out.push({ label: level, title: c.item_count + " records. Level is by record count, not value" });
  return out.slice(0, 4);
}

const CMTY_SORTS = [
  ["active", "Recently active", (a, b) => String(b.last_add || "").localeCompare(String(a.last_add || ""))],
  ["count", "Most records", (a, b) => b.item_count - a.item_count],
  ["value", "Highest value", (a, b) => b.total_value - a.total_value],
  ["followers", "Most followed", (a, b) => (b.followers - a.followers) || String(b.last_add || "").localeCompare(String(a.last_add || ""))],
];

function CmtyCollectors({ collectors, err, meId, soc, onViewCollection }) {
  const [sort, setSort] = useCmState("active");
  const rows = useCmMemo(() => {
    const s = CMTY_SORTS.find((x) => x[0] === sort) || CMTY_SORTS[0];
    return (collectors || []).slice().sort(s[2]);
  }, [collectors, sort]);
  if (err && !collectors) return <p className="cmty-empty">{CMTY_LOAD_ERR}</p>;
  if (!collectors) return <p className="cmty-empty">Loading collectors…</p>;
  const named = collectors.filter((c) => c.is_public).length;
  return (
    <div>
      <div className="cmty-tools">
        <div className="cmty-chips" role="group" aria-label="Sort by">
          {CMTY_SORTS.map(([k, label]) => (
            <button key={k} className={"cmty-chip" + (sort === k ? " on" : "")} aria-pressed={sort === k}
              onClick={() => { setSort(k); window.BBR_track && window.BBR_track("community_sort", { sort: k }); }}>{label}</button>
          ))}
        </div>
        <span className="cmty-meta">{named} named · {collectors.length - named} anonymous</span>
      </div>
      <div className="cmty-grid">
        {rows.map((c, idx) => {
          const me = !!(meId && c.user_id === meId);
          const open = () => onViewCollection(c.user_id, cmtyName(c, null));
          const since = c.is_public && c.member_since ? cmtySince(c.member_since) : null;
          const badges = cmtyBadges(c);
          return (
            <article className={"cmty-tile cmty-in" + (me ? " me" : "")} key={c.user_id} style={{ "--i": Math.min(idx, 8) }}>
              <header className="cmty-who">
                <CmtyAvatar actor={c} />
                <div className="cmty-line">
                  <button className="cmty-tile-n" onClick={open}>{cmtyName(c, meId)}</button>
                  {c.is_public && c.real_name && !(meId && c.user_id === meId) ? <span className="cmty-tile-real">{c.real_name}</span> : null}
                  <div className="cmty-meta">{c.is_public ? (since ? "Joined " + since : "Collector") : "Anonymous collector"}</div>
                </div>
                <CmtyFollow soc={soc} user={c} />
              </header>
              <button className="cmty-strip" onClick={open} aria-label={"Open " + cmtyName(c, null) + "’s shelf"}>
                {(c.front || []).map((it, i) => <CmtySleeve key={i} item={it} size={72} />)}
              </button>
              <CmtyGenreBar c={c} />
              <p className="cmty-desc">{cmtyDescribe(c)}</p>
              <div className="cmty-nums">
                <div><b>{cmtyNum(c.item_count)}</b>records</div>
                <div><b>{cmtyGbp(c.total_value)}</b>shelf value</div>
                <div><b>#{c.rank_value}</b>by value</div>
              </div>
              <div className="cmty-badges">{badges.map((b) => <span key={b.label} className={"cmty-badge" + (b.hot ? " hot" : "")} title={b.title}>{b.label}</span>)}</div>
              <footer className="cmty-acts">
                <span className="cmty-meta">{cmtyPlural(soc.followOf(c.user_id, c).followers, "follower")} · added {cmtyAgo(c.last_add)}</span>
                <button className="cmty-link" onClick={open}>View shelf</button>
              </footer>
            </article>
          );
        })}
      </div>
    </div>
  );
}

// `divesLoaded` is not read: it is passed so the page draws again once the dive
// catalogue has arrived, which is when "Read the deep dive" can be decided.
function Community({ tab, currentUserId, authReady, divesLoaded, onViewCollection, onRequireAuth }) {
  const soc = useCmtySocial(currentUserId || null, authReady, onRequireAuth);
  const [collectors, setCollectors] = useCmState(null);
  const [cErr, setCErr] = useCmState(false);
  const [count, setCount] = useCmState(cmtyFeedKeep.stats ? cmtyFeedKeep.stats.collectors : null);
  // The tiles are read when the Collectors tab is opened, as whoever is reading
  // (after auth has answered), and again if that changes. The feed tab used to
  // fetch all of them on every visit to print one number, which the feed's own
  // stats already carry.
  const wantFor = tab === "collectors" && authReady !== false ? (currentUserId || "anon") : null;
  const gotFor = useCmRef(null);
  useCmEffect(() => {
    if (!wantFor || gotFor.current === wantFor) return;
    if (!window.BBR_supabase) { setCErr(true); return; }
    let live = true;
    setCErr(false);
    Promise.resolve(window.BBR_supabase.rpc("community_collectors", {}))
      .then((out) => {
        if (!live) return;
        if (!out || out.error || !Array.isArray(out.data)) throw (out && out.error) || new Error("no data");
        gotFor.current = wantFor;
        setCollectors(out.data);
        setCount(out.data.length);
      })
      .catch((e) => { console.error("[community] collectors", e); if (live) setCErr(true); });
    return () => { live = false; };
  }, [wantFor]);
  useCmEffect(() => { window.BBR_track && window.BBR_track("community_tab", { tab }); }, [tab]);
  return (
    <div className="lb cmty">
      <div className="lb-head">
        <h1 className="lb-title">Community</h1>
        <p className="lb-sub">
          {tab === "collectors"
            ? "Every collector on The Lead-In and what their shelf is made of. Open any one to see their shelf; some appear under an alias."
            : "Posts, new records, new levels and pressings on the move, from every shelf on The Lead-In" + (count ? " (" + count + " collectors so far)." : ".")}
        </p>
      </div>
      <CommunityTabs tab={tab} />
      {tab === "collectors"
        ? <CmtyCollectors collectors={collectors} err={cErr} meId={currentUserId} soc={soc} onViewCollection={onViewCollection} />
        : <CmtyFeed meId={currentUserId} authReady={authReady} soc={soc} onViewCollection={onViewCollection} onRequireAuth={onRequireAuth}
            onStats={(s) => { if (s && s.collectors) setCount(s.collectors); }} />}
      <CmtySocialNote soc={soc} />
    </div>
  );
}

// ---- choosing a picture -----------------------------------------------------
// Opened from the collector's own profile. Three ways in, most personal first:
// records suggested from their own shelf (each with the reason it is offered),
// any record on the shelf by search, and the drawn labels. Nothing is saved
// until Save, so a tap is a preview.
const CMTY_AV_ERR = {
  not_your_record: "That record isn’t on your shelf any more.",
  sign_in: "Sign in again to choose a picture.",
  bad_media: "That photo didn’t upload properly. Try again.",
};
// A session that has lapsed is refused before the function runs, so it never
// raises its own 'sign_in': it arrives as a permission error.
function cmtyAvErr(e) {
  const m = String((e && e.message) || ""), c = String((e && e.code) || "");
  if (c === "42501" || c === "PGRST301" || /permission denied|JWT/i.test(m)) return CMTY_AV_ERR.sign_in;
  return CMTY_AV_ERR[m] || "That didn’t save. Try again in a moment.";
}
function CmtyAvatarPicker({ actor, current: given, auto, genres, items, onClose, onSaved }) {
  // Opened from Account settings it is not told the current picture: read it,
  // so it shows (UX review, 14 Oct 2026). And with no records, no shelf to
  // choose from: those sections are left out.
  const [current, setCurrent] = useCmState(given || null);
  useCmEffect(() => {
    if (given || !actor || !actor.user_id || !window.BBR_supabase) return undefined;
    let live = true;
    Promise.resolve(window.BBR_supabase.rpc("community_person", { p_user: actor.user_id })).then((out) => {
      if (live && out && !out.error && out.data && out.data.avatar && !out.data.avatar.auto) setCurrent(out.data.avatar);
    }, () => {});
    return () => { live = false; };
  }, []);
  const noShelf = Array.isArray(items) && items.length === 0;
  const [opts, setOpts] = useCmState(null);
  const [optsBad, setOptsBad] = useCmState(false);
  const [pick, setPick] = useCmState(null);        // { choice, avatar }, or null while untouched
  // Opens on the label they already have, if they have one, not on the colour
  // of their top genre with nothing marked.
  const has = current && current.kind === "label" ? current : null;
  const [hue, setHue] = useCmState(has ? (Number(has.hue) || 0) : cmtyHueForGenre(genres && genres[0]));
  // A double click on the link that opens this lands its second click on the
  // backdrop, which closed it again at once.
  const born = useCmRef(Date.now());
  const [q, setQ] = useCmState("");
  const [saving, setSaving] = useCmState(false);
  const [err, setErr] = useCmState("");
  const [reading, setReading] = useCmState(false);
  const fileRef = useCmRef(null);
  const onCloseRef = useCmRef(onClose); onCloseRef.current = onClose;
  // An uploaded photo (13 Oct 2026): cut to a square from the middle, 512
  // pixels, and only sent when Save is pressed.
  const photoUrl = useCmRef(null);
  useCmEffect(() => () => { if (photoUrl.current) URL.revokeObjectURL(photoUrl.current); }, []);
  const takePhoto = (f) => {
    if (!f) return;
    setErr(""); setReading(true);
    cmtyImageBlob(f, { square: 512, quality: 0.88 }).then((r) => {
      setReading(false);
      if (photoUrl.current) URL.revokeObjectURL(photoUrl.current);
      photoUrl.current = URL.createObjectURL(r.blob);
      setPick({ choice: "photo:new", avatar: { kind: "photo", url: photoUrl.current }, blob: r.blob });
    }, () => { setReading(false); setErr("That photo couldn’t be read. Try a JPEG or PNG."); });
  };

  useCmEffect(() => {
    let live = true;
    Promise.resolve(window.BBR_supabase.rpc("community_avatar_options", {})).then((out) => {
      if (!live) return;
      const ok = out && !out.error && Array.isArray(out.data);
      setOpts(ok ? out.data : []); setOptsBad(!ok);
    }, () => { if (live) { setOpts([]); setOptsBad(true); } });
    return () => { live = false; };
  }, []);
  // Body lock, back gesture, Escape: as the front-ten picker.
  useCmEffect(() => {
    const y = window.scrollY || 0, b = document.body;
    const prev = { position: b.style.position, top: b.style.top, left: b.style.left, right: b.style.right, width: b.style.width, overflow: b.style.overflow };
    Object.assign(b.style, { position: "fixed", top: "-" + y + "px", left: "0", right: "0", width: "100%", overflow: "hidden" });
    return () => { Object.assign(b.style, prev); window.scrollTo(0, y); };
  }, []);
  useCmEffect(() => {
    history.pushState({ bbrAvatar: true }, "", location.pathname + location.search + location.hash);
    let popped = false;
    const onPop = () => { popped = true; onCloseRef.current(); };
    window.addEventListener("popstate", onPop);
    // Step back over our own entry only. If the page has moved on underneath
    // (a link followed while this was open), back() would undo that move.
    return () => { window.removeEventListener("popstate", onPop); if (!popped && history.state && history.state.bbrAvatar) history.back(); };
  }, []);
  // A dialog: focus comes in, Tab stays in, and focus goes back where it was.
  const boxRef = useCmRef(null);
  useCmEffect(() => {
    const was = document.activeElement, box = boxRef.current;
    if (box) box.focus({ preventScroll: true });
    const onTab = (e) => {
      if (e.key !== "Tab" || !box) return;
      const f = box.querySelectorAll("button:not([disabled]), input, a[href]");
      if (!f.length) return;
      const first = f[0], last = f[f.length - 1], at = document.activeElement;
      if (!box.contains(at)) { e.preventDefault(); first.focus(); }
      else if (e.shiftKey && (at === first || at === box)) { e.preventDefault(); last.focus(); }
      else if (!e.shiftKey && at === last) { e.preventDefault(); first.focus(); }
    };
    document.addEventListener("keydown", onTab);
    return () => {
      document.removeEventListener("keydown", onTab);
      if (was && was.focus && document.contains(was)) { try { was.focus({ preventScroll: true }); } catch (e) {} }
    };
  }, []);
  useCmEffect(() => {
    const k = (e) => { if (e.key === "Escape" && !e.defaultPrevented) onCloseRef.current(); };
    window.addEventListener("keydown", k);
    return () => window.removeEventListener("keydown", k);
  }, []);

  const asCover = (r) => ({ kind: "cover", artist: r.artist, title: r.title, year: r.year, slug: r.slug || null,
                            discogs_release_id: r.discogs_release_id || null, cover: r.cover || null });
  const matches = useCmMemo(() => {
    const t = q.trim().toLowerCase();
    if (t.length < 2) return [];
    return (items || []).filter((r) => ((r.title || "") + " " + (r.artist || "")).toLowerCase().includes(t)).slice(0, 6);
  }, [items, q]);
  const shown = pick ? pick.avatar : current;
  const isPick = (choice) => pick ? pick.choice === choice
    : !!has && choice === "label:" + has.pattern + ":" + (Number(has.hue) || 0);

  function save() {
    if (!pick) { onClose(); return; }
    setSaving(true); setErr("");
    const choice = pick.blob
      ? cmtyUpload(cmtyUploadPath(actor.user_id, "jpg"), pick.blob, "image/jpeg").then((path) => "photo:" + path)
      : Promise.resolve(pick.choice);
    choice.then((c) => Promise.resolve(window.BBR_supabase.rpc("community_set_avatar", { p_choice: c })).then((out) => {
      setSaving(false);
      if (!out || out.error) { setErr(cmtyAvErr(out && out.error)); return; }
      window.BBR_track && window.BBR_track("community_avatar", { kind: c.split(":")[0] });
      onSaved(out.data || null, c !== "auto");
      onClose();
    }), (e) => { setSaving(false); setErr(cmtyUploadErr(e) || "That didn’t upload. Try again in a moment."); })
      .catch(() => { setSaving(false); setErr("That didn’t save. Try again in a moment."); });
  }

  const portal = window.bbrOverlayPortal || ((n) => n);
  return portal(
    <div className="av2-backdrop" onClick={() => { if (Date.now() - born.current > 450) onClose(); }}>
      <div className="av2-modal cmty-avp" onClick={(e) => e.stopPropagation()} role="dialog" aria-modal="true" aria-label="Choose your picture" tabIndex={-1} ref={boxRef}>
        <div className="av2-head">
          <div className="av2-head-left"><span className="av2-head-title">Your picture</span></div>
          <button className="av2-x" aria-label="Close" onClick={onClose}>×</button>
        </div>
        <div className="av2-body">
          <div className="cmty-avp-top">
            <CmtyAvatar actor={actor} avatar={shown} disc size={104} />
            <p>{!pick ? "This is how other collectors see you: on your shelf, beside your posts and comments, and in the notifications of people you like or follow."
                : pick.choice === "auto" && noShelf ? "The automatic picture: your initials, until you add a record. Save to keep it."
                : pick.choice === "auto" ? "The automatic picture: the first record in your front ten, or your most valuable one if you haven’t chosen a front ten. Save to keep it."
                : pick.blob ? "Your photo. Save to upload it and keep it."
                : "A preview. Save to keep it."}</p>
          </div>

          <h3 className="cmty-avp-h">A photo</h3>
          <div className="cmty-avp-photo">
            <button className={"cmty-avp-opt cmty-avp-up" + (pick && pick.blob ? " on" : "")} disabled={reading || saving} onClick={() => fileRef.current && fileRef.current.click()}>
              <svg viewBox="0 0 20 20" width="22" height="22" aria-hidden="true"><rect x="2.5" y="4" width="15" height="12" rx="2" fill="none" stroke="currentColor" strokeWidth="1.5" /><circle cx="10" cy="10" r="3" fill="none" stroke="currentColor" strokeWidth="1.5" /></svg>
              <span className="cmty-avp-t">{reading ? "Reading…" : pick && pick.blob ? "Choose another" : "Upload a photo"}</span>
            </button>
            <p className="cmty-meta">A square from the middle of the photo. Anyone can see it{actor && !actor.is_public ? ", beside your alias, so a photo of yourself would say who you are" : ""}. Photos that break the rules are taken down.</p>
            <input ref={fileRef} type="file" accept="image/*" hidden onChange={(e) => { takePhoto(e.target.files && e.target.files[0]); e.target.value = ""; }} />
          </div>

          {noShelf ? null : <React.Fragment>
          <h3 className="cmty-avp-h">From your shelf</h3>
          {opts === null ? <p className="cmty-meta">Looking through your records…</p> : null}
          {opts && !opts.length ? <p className="cmty-meta">{optsBad ? "Your suggestions didn’t load. You can still search your shelf or choose a label." : "Add a record and it can be your picture."}</p> : null}
          <div className="cmty-avp-grid">
            {(opts || []).map((o) => (
              <button key={o.choice} className={"cmty-avp-opt" + (isPick(o.choice) ? " on" : "")} aria-pressed={isPick(o.choice)}
                onClick={() => { setErr(""); setPick({ choice: o.choice, avatar: asCover(o) }); }}>
                <CmtyAvatar actor={actor} avatar={asCover(o)} size={64} />
                <span className="cmty-avp-t">{o.title}</span>
                <span className="cmty-meta">{o.why}</span>
              </button>
            ))}
          </div>

          <h3 className="cmty-avp-h">Or any record you own</h3>
          <input className="cmty-avp-q" type="search" value={q} onChange={(e) => setQ(e.target.value)} placeholder="Search your shelf" aria-label="Search your shelf" />
          {matches.map((r) => (
            <button key={r.id} className={"cmty-avp-row" + (isPick("item:" + r.id) ? " on" : "")} aria-pressed={isPick("item:" + r.id)}
              onClick={() => { setErr(""); setPick({ choice: "item:" + r.id, avatar: asCover(r) }); }}>
              <CmtyAvatar actor={actor} avatar={asCover(r)} size={36} />
              <span><b>{r.title}</b> <span className="cmty-meta">{r.artist}</span></span>
            </button>
          ))}
          {q.trim().length >= 2 && !matches.length ? <p className="cmty-meta">Nothing on your shelf matches that.</p> : null}
          </React.Fragment>}

          <h3 className="cmty-avp-h">Or a label</h3>
          <div className="cmty-avp-hues" role="group" aria-label="Label colour">
            {CMTY_LABEL_HUES.map((c, i) => (
              <button key={i} className={"cmty-avp-hue" + (hue === i ? " on" : "")} style={{ background: c }} aria-pressed={hue === i}
                aria-label={"Colour " + (i + 1) + (CMTY_LABEL_GENRES[i] ? ", " + CMTY_LABEL_GENRES[i] : "")} title={CMTY_LABEL_GENRES[i]}
                onClick={() => { setHue(i); if (pick && pick.choice.indexOf("label:") === 0) { const p = Number(pick.choice.split(":")[1]); setPick({ choice: "label:" + p + ":" + i, avatar: { kind: "label", pattern: p, hue: i } }); } }} />
            ))}
          </div>
          <div className="cmty-avp-labels">
            {CMTY_LABEL_NAMES.map((nm, i) => {
              const choice = "label:" + (i + 1) + ":" + hue;
              return (
                <button key={nm} className={"cmty-avp-opt" + (isPick(choice) ? " on" : "")} aria-pressed={isPick(choice)}
                  onClick={() => { setErr(""); setPick({ choice, avatar: { kind: "label", pattern: i + 1, hue } }); }}>
                  <CmtyAvatar actor={actor} avatar={{ kind: "label", pattern: i + 1, hue }} size={56} />
                  <span className="cmty-meta">{nm}</span>
                </button>
              );
            })}
          </div>
        </div>
        <div className="cmty-avp-foot">
          {err ? <p className="cmty-avp-err" role="alert">{err}</p> : null}
          <button className="cmty-link" onClick={() => { setErr(""); setPick({ choice: "auto", avatar: auto || null }); }}>Use the automatic picture</button>
          <button className="cmty-follow on" disabled={saving || reading || !pick} onClick={save}>{saving ? (pick && pick.blob ? "Uploading…" : "Saving…") : "Save"}</button>
        </div>
      </div>
    </div>
  );
}

// Who is reading: undefined until the session has been read (a tap before
// then must not open the sign-up gate over a member, see useCmtySocial), then
// followed. Signing in from the gate leaves the reader on this page
// ("community" is a stay-in-place intent in App.jsx): read once, a header went
// on thinking nobody was signed in until a reload.
function useCmtyMe() {
  const [meId, setMeId] = useCmState(undefined);
  useCmEffect(() => {
    let live = true;
    if (!window.BBR_supabase || !window.BBR_supabase.auth) { setMeId(null); return; }
    window.BBR_supabase.auth.getSession().then((res) => {
      const u = res && res.data && res.data.session && res.data.session.user;
      if (live) setMeId(u ? u.id : null);
    }, () => { if (live) setMeId(null); });
    const off = window.BBR_auth && window.BBR_auth.onChange
      ? window.BBR_auth.onChange((u) => { if (live) setMeId(u ? u.id : null); }) : null;
    return () => { live = false; if (off) { try { off(); } catch (e) {} } };
  }, []);
  return meId;
}

// CHANGING THE USERNAME WHERE IT IS SHOWN (13 Oct 2026, Ric: on My collection).
// The same two calls as Account settings (username_check, set_username in
// supabase/migrations/usernames.sql), and the same words for a refusal.
const CMTY_UNAME_ERR = {
  format: "3 to 30 characters: letters, numbers, and single hyphens or underscores inside.",
  reserved: "That one isn’t available.",
  taken: "That username is taken.",
  too_often: "You’ve changed it three times this month. Try again in a few weeks.",
};
function CmtyNameEdit({ onDone }) {
  const store = window.BBR_store || {};
  const mine = store.username || "";
  const [v, setV] = useCmState(mine);
  const [hint, setHint] = useCmState(null);
  const [busy, setBusy] = useCmState(false);
  const [msg, setMsg] = useCmState("");
  const errs = window.BBR_USERNAME_ERR || CMTY_UNAME_ERR;
  const clean = v.trim().toLowerCase();
  const inRef = useCmRef(null);
  useCmEffect(() => { if (inRef.current) { inRef.current.focus(); inRef.current.select(); } }, []);
  useCmEffect(() => {
    if (!clean || clean === mine) { setHint(null); return; }
    let live = true;
    const t = setTimeout(() => {
      Promise.resolve(window.BBR_supabase.rpc("username_check", { p_username: clean })).then((out) => {
        const d = live && out && out.data;
        if (!d) return;
        setHint(d.ok ? { ok: true, text: clean + " is available." } : { ok: false, text: errs[d.error] || "That one isn’t available." });
      }, () => {});
    }, 350);
    return () => { live = false; clearTimeout(t); };
  }, [clean]);
  const save = (e) => {
    if (e) e.preventDefault();
    if (!clean || clean === mine || busy || (hint && !hint.ok)) return;
    setBusy(true); setMsg("");
    Promise.resolve(window.BBR_supabase.rpc("set_username", { p_username: clean })).then((out) => {
      const d = out && out.data;
      if (!out || out.error || !d) throw new Error("failed");
      if (!d.ok) { setBusy(false); setHint(null); setMsg(errs[d.error] || "That one isn’t available."); return; }
      // Share images print the username, and the kept feed names the collector.
      if (d.is_public && store.purgeNamedImages) store.purgeNamedImages();
      cmtyFeedKeep.events = null;
      window.BBR_track && window.BBR_track("username_change", { where: "collection" });
      return Promise.resolve(store.loadProfile ? store.loadProfile() : null).then(() => {
        if (store.notify) store.notify();
        setBusy(false);
        onDone(d.is_public ? "Saved. You now appear as " + d.username + "." : "Saved. It stays hidden while you appear under an alias.");
      });
    }).catch(() => { setBusy(false); setMsg("Couldn’t save that just now. Try again in a moment."); });
  };
  return (
    <form className="cmty-uname" onSubmit={save}>
      <label className="cmty-vh" htmlFor="cmty-uname-in">Username</label>
      <input id="cmty-uname-in" ref={inRef} value={v} maxLength={30} autoCapitalize="none" autoCorrect="off" spellCheck={false}
        placeholder="e.g. crate_digger" aria-describedby="cmty-uname-hint"
        onChange={(e) => { setV(e.target.value); setMsg(""); }}
        onKeyDown={(e) => { if (e.key === "Escape") { e.preventDefault(); onDone(""); } }} />
      <button type="submit" className="cmty-follow on" disabled={busy || !clean || clean === mine || !!(hint && !hint.ok)}>{busy ? "Saving…" : "Save"}</button>
      <button type="button" className="cmty-link" onClick={() => onDone("")}>Cancel</button>
      <div id="cmty-uname-hint" className="cmty-uname-hint" role="status">
        {hint ? <span className={hint.ok ? "ok" : "bad"}>{hint.text}</span> : null}
        {msg ? <span className="bad">{msg}</span> : null}
        {!hint && !msg ? <span className="cmty-meta">Letters, numbers, - and _. It doesn’t have to be your name. Three changes a month.</span> : null}
      </div>
    </form>
  );
}

// ---- the top of a collector's shelf -----------------------------------------
// Who this is, before what they own (12 Oct 2026, Ric): a picture, the name,
// the level, follow and like, then the figures that carry a collector's weight
// set as a band, not a row of chips: shelf value, records, how many have a
// deep dive, how much is graded, and followers. Followers sit with the others
// on purpose. Record count is why a collector's opinion counts; so is who
// chooses to follow them.
//
// Rendered by PublicCollectionV2 (Leaderboards_v2.jsx), which owns the records
// and passes the figures in, and since 13 Oct 2026 at the top of the owner's
// own My collection (MyCollectionV2.jsx) with `band` off (the value bar below
// it carries the figures) and `editName` on. This owns what the item view does
// not carry: community_profile() for the followers, the reader's own follow
// and like, the picture and its ring.
function CommunityProfileHero({ userId, name, realName, isPublic, since, isOwner, total, count, canon, graded, items, onShare, shareReady, band, editName }) {
  const meId = useCmtyMe();
  // The page works out `isOwner` once per shelf; a reader who signs in here and
  // turns out to own it must not be offered Follow on themselves.
  const mine = !!isOwner || (!!meId && meId === userId);
  const soc = useCmtySocial(meId || null, meId !== undefined, window.__bbrRequireAuth);
  const [prof, setProf] = useCmState(null);
  const [picking, setPicking] = useCmState(false);
  const [naming, setNaming] = useCmState(false);
  const renameRef = useCmRef(null), wasNaming = useCmRef(false);
  // Back to the link once the box closes (saved, cancelled or Escape).
  useCmEffect(() => { if (!naming && wasNaming.current && renameRef.current) renameRef.current.focus(); wasNaming.current = naming; }, [naming]);
  const [said, setSaid] = useCmState("");
  const [askPic, setAskPic] = useCmState(false);
  const picNoRef = useCmRef(null), picSaidRef = useCmRef(null);
  const picBtnRef = useCmRef(null);
  const [picErr, setPicErr] = useCmState("");
  useCmEffect(() => { if (askPic && picNoRef.current) picNoRef.current.focus(); }, [askPic]);
  const [picBusy, setPicBusy] = useCmState(false);
  const [picSaid, setPicSaid] = useCmState("");
  useCmEffect(() => {
    if (!userId || meId === undefined || !window.BBR_supabase) return;
    let live = true;
    setProf(null);
    Promise.resolve(window.BBR_supabase.rpc("community_profile", { p_user: userId })).then((out) => {
      if (live && out && !out.error && out.data) setProf(out.data);
    }, () => {});
    return () => { live = false; };
  }, [userId, meId]);
  // Account settings sends the owner here to choose (BBR_OPEN_AVATAR). The
  // request names the collector and the moment: one left behind (signing out
  // does not reload the page)
  // must not open the chooser later, or for the next account on this tab.
  useCmEffect(() => {
    const want = window.BBR_OPEN_AVATAR;
    if (!want) return;
    if (want.user !== userId || !(Date.now() - want.at < 20000)) { window.BBR_OPEN_AVATAR = null; return; }
    if (isOwner) { window.BBR_OPEN_AVATAR = null; setPicking(true); }
  }, [isOwner, userId]);
  useCmEffect(() => { if (!said) return; const t = setTimeout(() => setSaid(""), 8000); return () => clearTimeout(t); }, [said]);

  const alias = isPublic || /^anonymous collector$/i.test(String(name || "").trim())
    ? null : String(name || "").replace(/^Anonymous\s+/, "");
  const actor = {
    user_id: userId, is_public: !!isPublic, display_name: isPublic ? name : null, anon_alias: alias,
    avatar: prof ? prof.avatar : null, following: prof ? prof.following : false, followers: prof ? prof.followers : 0,
    ring: prof ? prof.ring : null,
  };
  const followers = soc.followOf(userId, actor).followers;
  const topGenres = useCmMemo(() => {
    const n = {};
    (items || []).forEach((r) => { if (r.genre) n[r.genre] = (n[r.genre] || 0) + 1; });
    return Object.keys(n).sort((a, b) => n[b] - n[a]);
  }, [items]);
  const Badge = window.CollectorBadge;
  const showBand = band !== false;
  const photo = prof && prof.avatar && prof.avatar.kind === "photo";
  // An uploaded picture can be reported by anyone signed in, and taken down by
  // a moderator, as a post can (Ric, 13 Oct 2026).
  const picAct = () => {
    const staff = prof && prof.staff;
    setPicBusy(true);
    Promise.resolve(window.BBR_supabase.rpc(staff ? "community_picture_remove" : "community_picture_report", { p_user: userId })).then((out) => {
      if (!out || out.error) throw new Error("failed");
      setPicBusy(false); setAskPic(false);
      if (staff) { setProf((p) => Object.assign({}, p || {}, { avatar: (out.data && out.data.avatar) || null })); setPicSaid("Picture removed."); cmtyFeedKeep.events = null; cmtyAvatarChanged(); }
      else setPicSaid("Reported. Thank you, we’ll take a look.");
      setTimeout(() => { if (picSaidRef.current) picSaidRef.current.focus(); }, 0);
    // A failure keeps the button, so "try again" can be done (UX review, 14 Oct 2026).
    }).catch(() => { setPicBusy(false); setAskPic(false); setPicErr("That didn’t go through. Try again in a moment."); setTimeout(() => { if (picBtnRef.current) picBtnRef.current.focus(); }, 0); });
  };
  return (
    <React.Fragment>
      <div className={"pubcol-hero" + (showBand ? "" : " owner")}>
        <div className="pubcol-pic">
          <CmtyAvatar actor={actor} disc size={112} />
          {mine ? <button className="cmty-link" onClick={() => setPicking(true)}>{prof && prof.chosen ? "Change picture" : "Choose a picture"}</button> : null}
          {!mine && (photo || picSaid) && meId ? (
            askPic ? (
              <CmtyAsk q={prof.staff ? "Remove this picture for everyone?" : "Report this picture to the moderators?"}
                yes={prof.staff ? "Remove" : "Report"} no="Cancel" busy={picBusy} noRef={picNoRef} onYes={picAct}
                onNo={() => { setAskPic(false); setTimeout(() => { if (picBtnRef.current) picBtnRef.current.focus(); }, 0); }} />
            ) : picSaid ? <span className="cmty-meta" role="status" tabIndex={-1} ref={picSaidRef}>{picSaid}</span>
              : <React.Fragment>
                  <button className="cmty-link pubcol-pic-rep" ref={picBtnRef} onClick={() => { setPicErr(""); setAskPic(true); }}>{prof.staff ? "Remove picture" : "Report picture"}</button>
                  {picErr ? <span className="cmty-say-err" role="alert">{picErr}</span> : null}
                </React.Fragment>
          ) : null}
        </div>
        <div className="pubcol-id">
          <div className="pubcol-kicker">{isPublic ? "Collector" + (since ? " · joined " + since : "") : "Anonymous collector"}</div>
          {naming ? (
            <CmtyNameEdit onDone={(m) => { setNaming(false); if (m) setSaid(m); }} />
          ) : (
            // The button sits beside the heading, not in it: inside, the heading
            // was read as "Anonymous PettyChoose a username" (QA, 14 Oct 2026).
            <div className="pubcol-name">
              <h1 className="lb-title" dir="auto">{name}</h1>
              {editName && mine ? (
                <button className="cmty-link pubcol-rename" ref={renameRef} onClick={() => { setSaid(""); setNaming(true); }}
                  aria-label={isPublic ? "Change your username" : "Choose a username"}>
                  {isPublic ? "Edit" : (window.BBR_store && window.BBR_store.username ? "Change username" : "Choose a username")}
                </button>
              ) : null}
            </div>
          )}
          {said ? <div className="cmty-meta pubcol-said" role="status">{said}{!isPublic && window.__nav && window.__nav.goAccount ? <React.Fragment> <button className="cmty-link" onClick={() => window.__nav.goAccount()}>Appear under it</button></React.Fragment> : null}</div> : null}
          {/* Their own name, only if they have chosen to show it beside the username. */}
          {isPublic && realName && realName.toLowerCase() !== String(name || "").toLowerCase() ? <div className="pubcol-real">{realName}</div> : null}
          <div className="pubcol-level">{Badge ? <Badge count={count} /> : <b>{cmtyLevel(count)}</b>}
            {/* The ring in words, for anyone who cannot see a colour or hover a title. */}
            {cmtyRing(actor) ? <CmtyRingPill ring={actor.ring} /> : null}
          </div>
          {!showBand ? (
            <div className="pubcol-follows">
              <span><b>{prof ? cmtyNum(followers) : "–"}</b> {followers === 1 ? "follower" : "followers"}</span>
              <span><b>{prof ? cmtyNum(prof.follows || 0) : "–"}</b> following</span>
            </div>
          ) : null}
          <div className="pubcol-actions">
            {!mine ? <CmtyFollow soc={soc} user={actor} /> : null}
            {isOwner && onShare ? <button type="button" className="mc-invite pubcol-share" onClick={onShare} disabled={!shareReady}>Share your shelf</button> : null}
          </div>
        </div>
      </div>
      {showBand ? (
      <div className="lb-stats pubcol-band">
        <div className="lb-stat lb-stat-hero">
          {/* Plain figures on a shelf: the count-up read "£0 · 0 records" for a
              second or more first (UX review, 14 Oct 2026). */}
          <div className="lb-stat-n"><span className="cur">£</span>{Math.round(Number(total) || 0).toLocaleString("en-GB")}</div>
          <div className="lb-stat-l">Shelf value</div>
        </div>
        <div className="lb-stat"><div className="lb-stat-n">{(Number(count) || 0).toLocaleString("en-GB")}</div><div className="lb-stat-l">{count === 1 ? "Record" : "Records"}</div></div>
        <div className="lb-stat"><div className="lb-stat-n">{(Number(canon) || 0).toLocaleString("en-GB")}</div><div className="lb-stat-l">With a deep dive</div></div>
        <div className="lb-stat" title={"Share of this collection's value priced on the owner's own condition grades. The rest is priced as " + (window.BBR_ASSUMED_GRADE || "VG+") + " until they grade it."}>
          <div className="lb-stat-n">{graded ? <React.Fragment>{graded.full ? "100" : graded.pct}<small>%</small></React.Fragment> : "–"}</div>
          <div className="lb-stat-l">Graded</div>
        </div>
        {/* A dash until the count is known (it read "0" while loading, and for good
            if the read failed). Set plainly: the counting-up figure rewound to
            nought and spun up again on every Follow and Unfollow. */}
        <div className="lb-stat"><div className="lb-stat-n">{prof ? (Number(followers) || 0).toLocaleString("en-GB") : "–"}</div><div className="lb-stat-l">{followers === 1 ? "Follower" : "Followers"}</div></div>
      </div>
      ) : null}
      {picking ? (
        <CmtyAvatarPicker actor={actor} current={prof ? prof.avatar : null} auto={prof ? prof.auto : null} genres={topGenres} items={items}
          onClose={() => setPicking(false)}
          onSaved={(av, chosen) => {
            setProf((p) => Object.assign({}, p || {}, { avatar: av, chosen: !!chosen }));
            // The feed kept for the way back, and the Activity under this
            // header, were read with the old picture; let them be read again.
            cmtyAvatarChanged(userId);
          }} />
      ) : null}
      <CmtySocialNote soc={soc} />
    </React.Fragment>
  );
}

// A COLLECTOR'S OWN ACTIVITY, on their shelf (13 Oct 2026, Ric): the posts and
// new records they put on Community, newest first, so following someone shows
// what they have been up to and a shelf feels lived in. Posts are written on
// Community only (14 Oct 2026, Ric), not here. community_feed(p_user) reads one
// collector's posts and adds.
const CMTY_ACT_FIRST = 4;
// `nameKey` changes when the owner renames themselves, so their posts are read again.
function CommunityActivity({ userId, isOwner, nameKey }) {
  const meId = useCmtyMe();
  const mine = !!isOwner || (!!meId && meId === userId);
  const soc = useCmtySocial(meId || null, meId !== undefined, window.__bbrRequireAuth);
  const [events, setEvents] = useCmState(null);
  const [more, setMore] = useCmState(false);
  const [busy, setBusy] = useCmState(false);
  const [err, setErr] = useCmState(false);
  // A screenful to start with, fading out, and the rest on request: four posts
  // with their photos and comments ran several screens (Ric, 14 Oct 2026).
  // Tabbing into what is cut off opens it, so the keyboard never lands on
  // something out of sight; what shows can be used as it is.
  const [wide, setWide] = useCmState(false);
  const [tall, setTall] = useCmState(false);
  const listRef = useCmRef(null), firstHid = useCmRef(-1), moreRef = useCmRef(null), focusAt = useCmRef(-1);
  // After "Show earlier activity", the keyboard goes on at the first new post.
  useCmEffect(() => {
    if (focusAt.current < 0 || !listRef.current) return;
    const el = listRef.current.children[focusAt.current];
    if (el) { el.focus({ preventScroll: true }); focusAt.current = -1; }
  }, [events]);
  // Read again when the window changes size (a phone turned on its side).
  const [narrow, setNarrow] = useCmState(() => typeof window !== "undefined" && window.innerWidth < 640);
  useCmEffect(() => {
    const f = () => setNarrow(window.innerWidth < 640);
    window.addEventListener("resize", f);
    return () => window.removeEventListener("resize", f);
  }, []);
  // At least the whole first post: the clip cut it mid-photo, with no way to
  // like or comment until "Show more" (UX review, 14 Oct 2026).
  const [firstH, setFirstH] = useCmState(0);
  const clip = Math.max(narrow ? 420 : 560, firstH ? firstH + 8 : 0);
  // Before paint, so a long Activity never shows in full for a frame first.
  React.useLayoutEffect(() => {
    const box = listRef.current;
    if (!box) return undefined;
    const mark = () => {
      const f = box.firstElementChild;
      if (f) setFirstH(f.offsetHeight);
      setTall(box.scrollHeight > clip + 40);
      // The first post wholly out of sight: where "Show more" takes the keyboard.
      firstHid.current = Array.from(box.children).findIndex((el) => el.offsetTop >= clip - 40);
    };
    mark();
    const ro = window.ResizeObserver ? new ResizeObserver(mark) : null;
    if (ro) Array.from(box.children).forEach((el) => ro.observe(el));
    return () => { if (ro) ro.disconnect(); };
  }, [events, wide, clip]);
  const req = useCmRef(0);
  // A link to the shelf this Activity is on goes to the records below rather
  // than opening the same page again at the top.
  const actRef = useCmRef(null);
  const onView = (uid) => {
    if (uid === userId) {
      let el = actRef.current && actRef.current.closest(".mc-activity, .cmty-act");
      el = el && el.nextElementSibling;
      if (el) { try { el.scrollIntoView({ block: "start", behavior: "smooth" }); } catch (e) { el.scrollIntoView(); } }
      return;
    }
    if (window.__nav && window.__nav.goPublicCollection) window.__nav.goPublicCollection(uid, "");
  };
  const load = (after) => {
    if (!window.BBR_supabase || !userId) return;
    const id = ++req.current;
    setBusy(true); setErr(false);
    Promise.resolve(window.BBR_supabase.rpc("community_feed", {
      p_limit: after ? 10 : CMTY_ACT_FIRST, p_following: false, p_user: userId,
      p_before: after ? after.at : null, p_before_key: after ? after.key : null,
    })).then((out) => {
      if (id !== req.current) return;
      if (!out || out.error || !out.data) throw (out && out.error) || new Error("no data");
      const rows = out.data.events || [];
      setEvents((prev) => (after && prev ? prev.concat(rows) : rows));
      setMore(!!out.data.more);
    }).catch(() => { if (id === req.current) setErr(true); })
      .then(() => { if (id === req.current) setBusy(false); });
  };
  // Read as whoever is reading, once that is known (their likes ride on the rows).
  useCmEffect(() => { if (meId === undefined) return; setEvents(null); load(null); return () => { req.current++; }; }, [userId, meId, nameKey || null]);
  // A new picture (chosen by the owner, or taken down by a moderator) shows on
  // the posts and comments here too, not only in the header (QA, 14 Oct 2026).
  useCmEffect(() => {
    if (meId === undefined) return;
    const f = (e) => { if (!e.detail || e.detail === userId) load(null); };
    window.addEventListener("bbr-cmty-avatar", f);
    return () => window.removeEventListener("bbr-cmty-avatar", f);
  }, [userId, meId]);
  const onTalk = (key, patch) => setEvents((es) => (es ? es.map((e) => (e.key === key ? Object.assign({}, e, patch) : e)) : es));
  if (!userId) return null;
  return (
    <CmtyOnShelf.Provider value={userId}>
    <section className="cmty-act" aria-labelledby={"cmty-act-h-" + userId} ref={actRef}>
      <div className="cmty-act-head">
        <h2 className="cmty-act-h" id={"cmty-act-h-" + userId}>{mine ? "Your activity" : "Activity"}</h2>
        {window.__nav && window.__nav.goCommunity ? <a className="cmty-link" href="/community" onClick={(e) => { if (e.metaKey || e.ctrlKey || e.shiftKey || e.button !== 0) return; e.preventDefault(); window.__nav.goCommunity(); }}>All of Community</a> : null}
      </div>
      {err && !events ? <p className="cmty-empty">The activity didn’t load. <button className="cmty-link" onClick={() => load(null)}>Try again</button></p> : null}
      {!events && !err ? <p className="cmty-empty">Loading the activity…</p> : null}
      {events && !events.length ? (
        <p className="cmty-empty">{mine ? "Nothing here yet. What you post on Community, and the records you add, show here." : "Nothing here yet."}</p>
      ) : null}
      <CmtyDated.Provider value={true}>
        {events && events.length ? (
          <div className={"cmty-act-list" + (!wide && tall ? " clipped" : "")} ref={listRef} style={!wide && tall ? { maxHeight: clip } : undefined}
            onFocus={(e) => {
              // Not focus in a dialog opened from a post here: React passes it
              // up through the portal, and opening a record sheet opened this.
              if (wide || !tall || !listRef.current || !listRef.current.contains(e.target)) return;
              const box = listRef.current, b = e.target.getBoundingClientRect(), top = box.getBoundingClientRect().top;
              // The browser has already scrolled the box to show it: count that back in.
              if (b.bottom - top + box.scrollTop > clip - 20) { box.scrollTop = 0; setWide(true); }
            }}>
            {events.map((ev) => (
              <div className="cmty-in" key={ev.key} tabIndex={-1}>
                <CmtyPostOf ev={ev} meId={meId || null} soc={soc} onViewCollection={onView} onTalk={onTalk} focus={false} onGone={() => {}} />
              </div>
            ))}
          </div>
        ) : null}
      </CmtyDated.Provider>
      {events && !wide && (tall || more) ? (
        <button className="cmty-loadmore" ref={moreRef} aria-expanded="false" onClick={() => {
          // Nothing cut off, only more to fetch: fetch it now (it took two taps).
          if (!tall && more) { setWide(true); focusAt.current = events.length; load(events[events.length - 1]); return; }
          const at = firstHid.current;
          setWide(true);
          // Focus to the first post that was cut off, so the keyboard carries on there.
          setTimeout(() => {
            const kids = listRef.current ? listRef.current.children : [];
            const el = kids[at >= 0 ? at : kids.length - 1];
            if (el) el.focus({ preventScroll: true });
          }, 0);
        }}>Show more activity</button>
      ) : null}
      {events && wide && more ? (
        <button className="cmty-loadmore" disabled={busy} onClick={() => { focusAt.current = events.length; load(events[events.length - 1]); }}>{busy ? "Loading…" : "Show earlier activity"}</button>
      ) : null}
      {events && wide && tall ? (
        <button className="cmty-link cmty-act-less" aria-expanded="true" onClick={() => {
          setWide(false);
          const h = document.getElementById("cmty-act-h-" + userId);
          // Back to the heading if it has gone above the screen, clear of the
          // site's bar and My collection's section bar.
          if (h && h.getBoundingClientRect().top < 130) window.scrollTo(0, window.scrollY + h.getBoundingClientRect().top - 140);
          setTimeout(() => { if (moreRef.current) moreRef.current.focus({ preventScroll: true }); }, 0);
        }}>Show less</button>
      ) : null}
      <CmtySocialNote soc={soc} />
    </section>
    </CmtyOnShelf.Provider>
  );
}

// The feed kept for the way back names this collector on their own posts and
// comments. Account settings calls this after a change of username, name on
// show, alias or visibility, so the next visit reads it again.
function cmtyIdentityChanged() { cmtyFeedKeep.events = null; cmtyMeKeep.actor = null; }
// A collector's picture changed: what shows their posts and comments reads them again.
function cmtyAvatarChanged(user) { cmtyFeedKeep.events = null; try { window.dispatchEvent(new CustomEvent("bbr-cmty-avatar", { detail: user || null })); } catch (e) {} }
Object.assign(window, { Community, CommunityTabs, CommunityNavBadge, CommunityProfileHero, CommunityActivity, CmtyAvatar, CmtyAvatarPicker, BBR_communityStale: cmtyIdentityChanged });
