Repository: lichess-org/lila
Pull request #17226: live board chat plugin for broadcasts
Author: login: jonbgamble
Created: 2025-03-24T17:45:03Z | Merged: 2025-03-26T08:40:42Z

give option to show live board in place of chat for broadcasts. this feature seems awful redundant for people who can see their multi-boards below, so it could be restricted to contributors to make loepare happy. there are no player bars (those would be redundant as well), the orientation is fixed at white.

also let tournament players hide chat, clean up chat state functions

i imagine you will rightly have concerns about `multiRedraw`, hit me up on zulip or discord.


Diff:
diff --git a/modules/coreI18n/src/main/key.scala b/modules/coreI18n/src/main/key.scala
index 583812f9db1db..c1619a25189f6 100644
--- a/modules/coreI18n/src/main/key.scala
+++ b/modules/coreI18n/src/main/key.scala
@@ -124,6 +124,8 @@ object I18nKey:
     val `tournamentShields`: I18nKey = "arena:tournamentShields"
     val `onlyTitled`: I18nKey = "arena:onlyTitled"
     val `onlyTitledHelp`: I18nKey = "arena:onlyTitledHelp"
+    val `tournamentPairingsAreNowClosed`: I18nKey = "arena:tournamentPairingsAreNowClosed"
+    val `berserkRate`: I18nKey = "arena:berserkRate"
     val `drawingWithinNbMoves`: I18nKey = "arena:drawingWithinNbMoves"
     val `viewAllXTeams`: I18nKey = "arena:viewAllXTeams"
 
@@ -1748,7 +1750,6 @@ object I18nKey:
     val `blackWins`: I18nKey = "blackWins"
     val `drawRate`: I18nKey = "drawRate"
     val `draws`: I18nKey = "draws"
-    val `nextXTournament`: I18nKey = "nextXTournament"
     val `averageOpponent`: I18nKey = "averageOpponent"
     val `boardEditor`: I18nKey = "boardEditor"
     val `setTheBoard`: I18nKey = "setTheBoard"
diff --git a/translation/source/broadcast.xml b/translation/source/broadcast.xml
index 4a59e0ea24aad..638cbbe53b898 100644
--- a/translation/source/broadcast.xml
+++ b/translation/source/broadcast.xml
@@ -80,4 +80,5 @@
   <string name="allBroadcastsByMonth">View all broadcasts by month</string>
   <string name="backToLiveMove">Back to live move</string>
   <string name="sinceHideResults">Since you chose to hide the results, all the preview boards are empty to avoid spoilers.</string>
+  <string name="liveboard">Live board</string>
 </resources>
diff --git a/ui/@types/lichess/i18n.d.ts b/ui/@types/lichess/i18n.d.ts
index 60e8461dd476d..237d654456af2 100644
--- a/ui/@types/lichess/i18n.d.ts
+++ b/ui/@types/lichess/i18n.d.ts
@@ -289,6 +289,8 @@ interface I18n {
     howToUseLichessBroadcasts: string;
     /** More options on the %s */
     iframeHelp: I18nFormat;
+    /** Live board */
+    liveboard: string;
     /** Live tournament broadcasts */
     liveBroadcasts: string;
     /** My broadcasts */
diff --git a/ui/analyse/css/study/relay/_back-to-live.scss b/ui/analyse/css/study/relay/_back-to-live.scss
index 7e3ff9c9b09a7..6a017c1a4c197 100644
--- a/ui/analyse/css/study/relay/_back-to-live.scss
+++ b/ui/analyse/css/study/relay/_back-to-live.scss
@@ -12,3 +12,20 @@
     display: none;
   }
 }
+
+.chat-liveboard {
+  display: flex;
+  position: relative;
+  width: 100%;
+  aspect-ratio: 1;
+}
+
+.mchat:has(.chat-liveboard) {
+  box-shadow: none;
+}
+.mchat .liveboard {
+  background-color: transparent;
+  cg-board {
+    border-top-right-radius: 0;
+  }
+}
diff --git a/ui/analyse/src/ctrl.ts b/ui/analyse/src/ctrl.ts
index 31e3bc6083d90..12fbce3ce4d7b 100644
--- a/ui/analyse/src/ctrl.ts
+++ b/ui/analyse/src/ctrl.ts
@@ -7,7 +7,14 @@ import { debounce, throttle } from 'lib/async';
 import type GamebookPlayCtrl from './study/gamebook/gamebookPlayCtrl';
 import type StudyCtrl from './study/studyCtrl';
 import { isTouchDevice } from 'lib/device';
-import type { AnalyseOpts, AnalyseData, ServerEvalData, JustCaptured, NvuiPlugin } from './interfaces';
+import type {
+  AnalyseOpts,
+  AnalyseData,
+  ServerEvalData,
+  JustCaptured,
+  NvuiPlugin,
+  MultiRedraw,
+} from './interfaces';
 import type { Api as ChessgroundApi } from 'chessground/api';
 import { Autoplay, AutoplayDelay } from './autoplay';
 import { build as makeTree, path as treePath, ops as treeOps, type TreeWrapper } from 'lib/tree/tree';
@@ -124,7 +131,7 @@ export default class AnalyseCtrl {
 
   constructor(
     readonly opts: AnalyseOpts,
-    readonly redraw: Redraw,
+    readonly redraw: MultiRedraw,
     makeStudy?: typeof StudyCtrl,
   ) {
     this.data = opts.data;
diff --git a/ui/analyse/src/interfaces.ts b/ui/analyse/src/interfaces.ts
index da171de50e7f2..ecd15a5e19bcb 100644
--- a/ui/analyse/src/interfaces.ts
+++ b/ui/analyse/src/interfaces.ts
@@ -3,7 +3,7 @@ import type { Player, Status, Source, Clock } from 'lib/game/game';
 import type { ForecastData } from './forecast/interfaces';
 import type { StudyPracticeData, Goal as PracticeGoal } from './study/practice/interfaces';
 import type { RelayData } from './study/relay/interfaces';
-import type { ChatCtrl } from 'lib/chat/chat';
+import type { ChatCtrl, ChatPlugin } from 'lib/chat/chat';
 import type { ExplorerOpts } from './explorer/interfaces';
 import type { StudyDataFromServer } from './study/interfaces';
 import type { AnalyseSocketSend } from './socket';
@@ -149,6 +149,7 @@ export interface AnalyseOpts {
   $side?: Cash;
   $underboard?: Cash;
   chat: {
+    plugin: ChatPlugin;
     enhance: EnhanceOpts;
     instance?: ChatCtrl;
   };
@@ -184,3 +185,7 @@ export interface AnalyseState {
   path: Tree.Path | undefined;
   flipped: boolean;
 }
+
+export type MultiRedraw = Redraw & {
+  add: (redraw: Redraw) => void;
+};
diff --git a/ui/analyse/src/start.ts b/ui/analyse/src/start.ts
index c0ba0b5707e9e..e33580b9f38f6 100644
--- a/ui/analyse/src/start.ts
+++ b/ui/analyse/src/start.ts
@@ -1,7 +1,7 @@
 import makeCtrl from './ctrl';
 import menuHover from 'lib/menuHover';
 import makeView from './view/main';
-import type { AnalyseApi, AnalyseOpts } from './interfaces';
+import type { AnalyseApi, AnalyseOpts, MultiRedraw } from './interfaces';
 import type { VNode } from 'snabbdom';
 import type * as studyDeps from './study/studyDeps';
 
@@ -12,7 +12,7 @@ export default function (
   return function (opts: AnalyseOpts): AnalyseApi {
     opts.element = document.querySelector('main.analyse') as HTMLElement;
 
-    const ctrl = (site.analysis = new makeCtrl(opts, redraw, deps?.StudyCtrl));
+    const ctrl = (site.analysis = new makeCtrl(opts, multiRedraw(redraw), deps?.StudyCtrl));
     const view = makeView(deps);
 
     const blueprint = view(ctrl);
@@ -34,3 +34,16 @@ export default function (
     };
   };
 }
+
+function multiRedraw(base: Redraw): MultiRedraw {
+  // to build redraws involving multiple patch functions.
+  const redraws: Redraw[] = [base];
+  const multi: MultiRedraw = () => {
+    for (const r of redraws) r();
+  };
+
+  multi.add = (r: Redraw) => {
+    redraws.push(r);
+  };
+  return multi;
+}
diff --git a/ui/analyse/src/study/interfaces.ts b/ui/analyse/src/study/interfaces.ts
index 82fa01cb64031..6ed559435812c 100644
--- a/ui/analyse/src/study/interfaces.ts
+++ b/ui/analyse/src/study/interfaces.ts
@@ -307,4 +307,5 @@ export type WithWhoAndChap = WithWho & WithChapterId;
 export interface ChapterSelect {
   is: (idOrNumber: ChapterId | number) => boolean;
   set: (idOrNumber: ChapterId | number, force?: boolean) => Promise<boolean>;
+  get: () => ChapterId;
 }
diff --git a/ui/analyse/src/study/relay/relayChat.ts b/ui/analyse/src/study/relay/relayChat.ts
new file mode 100644
index 0000000000000..6a256a1106914
--- /dev/null
+++ b/ui/analyse/src/study/relay/relayChat.ts
@@ -0,0 +1,72 @@
+import { type RelayViewContext } from '../../view/components';
+import type { StudyChapters } from '../studyChapters';
+import { spinnerVdom } from 'lib/controls';
+import { looseH as h, VNode, onInsert } from 'lib/snabbdom';
+import { getChessground, initMiniBoardWith } from 'lib/miniBoard';
+import { type ChatPlugin, makeChat } from 'lib/chat/chat';
+import { watchers } from 'lib/watchers';
+import { uciToMove } from 'chessground/util';
+import { frag } from 'lib';
+import { ChapterId } from '../interfaces';
+
+export function relayChatView({ ctrl, relay }: RelayViewContext): VNode | undefined {
+  if (ctrl.isEmbed || !ctrl.opts.chat) return undefined;
+  return h('section.mchat.mchat-optional', {
+    hook: onInsert(el => {
+      ctrl.opts.chat.instance?.destroy();
+      ctrl.opts.chat.instance = makeChat({
+        ...ctrl.opts.chat,
+        plugin: relay.chatCtrl,
+        enhance: { plies: true, boards: true },
+      });
+      const members = frag<HTMLElement>('<div class="chat__members">');
+      el.parentElement?.append(members);
+      watchers(members);
+    }),
+  });
+}
+
+export class RelayChatPlugin implements ChatPlugin {
+  private chapter: ChapterId | undefined;
+  private animate = false;
+
+  key = 'liveboard';
+  name = i18n.broadcast.liveboard;
+  kidSafe = true;
+  redraw: Redraw;
+
+  constructor(
+    readonly chapters: StudyChapters,
+    readonly isDisabled: () => boolean,
+  ) {}
+
+  set chapterId(id: ChapterId) {
+    if (id === this.chapter) return;
+    this.chapter = id;
+    this.animate = false;
+  }
+
+  get hidden(): boolean {
+    return this.isDisabled() || !this.chapter;
+  }
+
+  view(): VNode {
+    const preview = this.chapters.get(this.chapter || 0);
+    return preview
+      ? h('div.chat-liveboard', {
+          hook: {
+            insert: (vn: VNode) =>
+              initMiniBoardWith(vn.elm as HTMLElement, preview.fen, 'white', preview.lastMove),
+            update: (_, vn: VNode) => {
+              getChessground(vn.elm as HTMLElement)?.set({
+                fen: preview.fen,
+                lastMove: uciToMove(preview.lastMove),
+                animation: { enabled: this.animate },
+              });
+              this.animate = true;
+            },
+          },
+        })
+      : spinnerVdom();
+  }
+}
diff --git a/ui/analyse/src/study/relay/relayCtrl.ts b/ui/analyse/src/study/relay/relayCtrl.ts
index c82fab7d8f5de..075ea5b72b6fd 100644
--- a/ui/analyse/src/study/relay/relayCtrl.ts
+++ b/ui/analyse/src/study/relay/relayCtrl.ts
@@ -9,7 +9,9 @@ import type { StudyChapters } from '../studyChapters';
 import type { MultiCloudEval } from '../multiCloudEval';
 import { VideoPlayer } from './videoPlayer';
 import RelayStats from './relayStats';
+import { RelayChatPlugin } from './relayChat';
 import { pubsub } from 'lib/pubsub';
+import type { MultiRedraw } from '../../interfaces';
 
 export const relayTabs = ['overview', 'boards', 'teams', 'players', 'stats'] as const;
 export type RelayTab = (typeof relayTabs)[number];
@@ -27,18 +29,20 @@ export default class RelayCtrl {
   streams: [string, string][] = [];
   showStreamerMenu = toggle(false);
   videoPlayer?: VideoPlayer;
+  chatCtrl: RelayChatPlugin;
 
   constructor(
     readonly id: RoundId,
     public data: RelayData,
     readonly send: AnalyseSocketSend,
-    readonly redraw: (redrawOnly?: boolean) => void,
+    readonly baseRedraw: MultiRedraw,
     readonly isEmbed: boolean,
     readonly members: StudyMemberCtrl,
-    private readonly chapters: StudyChapters,
+    readonly chapters: StudyChapters,
     private readonly multiCloudEval: MultiCloudEval | undefined,
     private readonly federations: () => Federations | undefined,
     chapterSelect: ChapterSelect,
+    private readonly updateHistoryAndAddressBar: () => void,
   ) {
     this.tourShow = toggle((location.pathname.split('/broadcast/')[1].match(/\//g) || []).length < 3);
     const locationTab = location.hash.replace(/^#(\w+).*$/, '$1') as RelayTab;
@@ -49,16 +53,16 @@ export default class RelayCtrl {
         : 'boards';
     this.tab = prop<RelayTab>(initialTab);
     this.teams = data.tour.teamTable
-      ? new RelayTeams(id, this.multiCloudEval, chapterSelect, this.roundPath, redraw)
+      ? new RelayTeams(id, this.multiCloudEval, chapterSelect, this.roundPath, this.redraw)
       : undefined;
     this.players = new RelayPlayers(
       data.tour.id,
       () => this.openTab('players'),
       this.isEmbed,
       this.federations,
-      redraw,
+      this.redraw,
     );
-    this.stats = new RelayStats(this.currentRound(), redraw);
+    this.stats = new RelayStats(this.currentRound(), this.redraw);
     if (data.videoUrls?.[0] || this.isPinnedStreamOngoing())
       this.videoPlayer = new VideoPlayer(
         {
@@ -67,11 +71,13 @@ export default class RelayCtrl {
           image: this.data.tour.image,
           text: this.data.pinned?.text,
         },
-        redraw,
+        this.redraw,
       );
     const pinnedName = this.isPinnedStreamOngoing() && data.pinned?.name;
     if (pinnedName) this.streams.push(['ps', pinnedName]);
-
+    this.chatCtrl = new RelayChatPlugin(this.chapters, this.tourShow);
+    this.chatCtrl.chapterId = chapterSelect.get();
+    this.baseRedraw.add(() => this.chatCtrl.redraw?.());
     pubsub.on('socket.in.crowd', d => {
       const s = (d.streams as [string, string][]) ?? [];
       if (pinnedName) s.unshift(['ps', pinnedName]);
@@ -79,9 +85,14 @@ export default class RelayCtrl {
       this.streams = s;
       this.redraw();
     });
-    setInterval(() => this.redraw(true), 1000);
+    setInterval(this.baseRedraw, 1000);
   }
 
+  redraw = () => {
+    this.baseRedraw();
+    this.updateHistoryAndAddressBar();
+  };
+
   openTab = (t: RelayTab) => {
     this.players.closePlayer();
     this.tab(t);
@@ -89,11 +100,12 @@ export default class RelayCtrl {
     this.redraw();
   };
 
-  onChapterChange = () => {
+  onChapterChange = (id: ChapterId) => {
     if (this.tourShow()) {
       this.tourShow(false);
-      this.redraw();
     }
+    this.chatCtrl.chapterId = id;
+    this.redraw();
   };
 
   lastMoveAt = (id: ChapterId): number | undefined => this.chapters.get(id)?.lastMoveAt;
diff --git a/ui/analyse/src/study/relay/relayTourView.ts b/ui/analyse/src/study/relay/relayTourView.ts
index 1229d562932db..f7f29b4cd7b73 100644
--- a/ui/analyse/src/study/relay/relayTourView.ts
+++ b/ui/analyse/src/study/relay/relayTourView.ts
@@ -12,14 +12,14 @@ import { toggle, copyMeInput } from 'lib/controls';
 import { text as xhrText } from 'lib/xhr';
 import { teamsView } from './relayTeams';
 import { statsView } from './relayStats';
-import { makeChatEl, type RelayViewContext } from '../../view/components';
+import { type RelayViewContext } from '../../view/components';
 import { gamesList } from './relayGames';
 import { renderStreamerMenu } from './relayView';
 import { playersView } from './relayPlayers';
 import { gameLinksListener } from '../studyChapters';
 import { baseUrl } from '../../view/util';
 import { commonDateFormat, timeago } from 'lib/i18n';
-import { watchers } from 'lib/watchers';
+import { relayChatView } from './relayChat';
 
 export function renderRelayTour(ctx: RelayViewContext): VNode | undefined {
   const tab = ctx.relay.tab();
@@ -79,13 +79,7 @@ export const tourSide = (ctx: RelayViewContext) => {
           ]),
       !ctrl.isEmbed && relay.showStreamerMenu() && renderStreamerMenu(relay),
       !empty && gamesList(study, relay),
-      !ctrl.isEmbed &&
-        h('div.chat__members', {
-          hook: onInsert(el => {
-            makeChatEl(ctrl, chat => el.parentNode!.insertBefore(chat, el));
-            watchers(el);
-          }),
-        }),
+      relayChatView(ctx),
     ],
   );
 };
diff --git a/ui/analyse/src/study/studyCtrl.ts b/ui/analyse/src/study/studyCtrl.ts
index 09c246595754b..d38518ae2d551 100644
--- a/ui/analyse/src/study/studyCtrl.ts
+++ b/ui/analyse/src/study/studyCtrl.ts
@@ -165,13 +165,14 @@ export default class StudyCtrl {
         this.data.id,
         relayData,
         this.send,
-        (redrawOnly = false) => (redrawOnly ? this.redraw() : this.redrawAndUpdateAddressBar()),
+        ctrl.redraw,
         ctrl.isEmbed,
         this.members,
         this.chapters.list,
         this.multiCloudEval,
         () => this.data.federations,
         this.chapterSelect,
+        this.updateHistoryAndAddressBar,
       );
     this.multiBoard = new MultiBoardCtrl(
       this.chapters.list,
@@ -381,7 +382,7 @@ export default class StudyCtrl {
     this.commentForm.onSetPath(this.data.chapter.id, this.ctrl.path, this.ctrl.node);
     this.redraw();
     this.ctrl.startCeval();
-    this.updateAddressBar();
+    this.updateHistoryAndAddressBar();
   };
 
   xhrReload = throttlePromiseDelay(
@@ -469,13 +470,13 @@ export default class StudyCtrl {
       console.warn(`Chapter ${idOrNumber} not found`);
       return false;
     }
-    const componentCallbacks = () => {
-      this.relay?.onChapterChange();
-      this.multiBoard.onChapterChange(this.data.chapter.id);
+    const componentCallbacks = (id: ChapterId) => {
+      this.relay?.onChapterChange(id);
+      this.multiBoard.onChapterChange(id);
     };
     const alreadySet = id === this.vm.chapterId && !force;
     if (alreadySet) {
-      componentCallbacks();
+      componentCallbacks(this.data.chapter.id);
       this.redraw();
       return true;
     }
@@ -491,8 +492,8 @@ export default class StudyCtrl {
       this.vm.mode.sticky = false;
       if (!this.vm.behind) this.vm.behind = 1;
       this.vm.chapterId = id;
+      componentCallbacks(id);
       await this.xhrReload();
-      componentCallbacks();
     }
     window.scrollTo(0, 0);
     return true;
@@ -501,6 +502,7 @@ export default class StudyCtrl {
   chapterSelect: ChapterSelect = {
     is: (idOrNumber: ChapterId | number) => defined(this.chapters.list.get(idOrNumber)),
     set: this.setChapter,
+    get: () => this.data.chapter.id,
   };
 
   private deltaChapter = (delta: number): ChapterPreview | undefined => {
@@ -614,17 +616,13 @@ export default class StudyCtrl {
     const studyIdOffset = current.indexOf(`/${this.data.id}`);
     return studyIdOffset === -1 ? `/study/${this.data.id}` : current.slice(0, studyIdOffset + 9);
   };
-  updateAddressBar = () => {
+  updateHistoryAndAddressBar = () => {
     if (this.ctrl.isEmbed) return;
     const studyUrl = this.baseUrl();
     const chapterUrl = `${studyUrl}/${this.vm.chapterId}`;
     if (this.relay) this.relay.updateAddressBar(studyUrl, chapterUrl);
     else if (chapterUrl !== location.href) history.replaceState({}, '', chapterUrl);
   };
-  redrawAndUpdateAddressBar = () => {
-    this.redraw();
-    this.updateAddressBar();
-  };
   socketHandler = (t: string, d: any) => {
     const handler = (this.socketHandlers as any as SocketHandlers)[t];
     if (handler) {
diff --git a/ui/analyse/src/view/components.ts b/ui/analyse/src/view/components.ts
index b7575ac43fc35..02c8f00c51ffc 100644
--- a/ui/analyse/src/view/components.ts
+++ b/ui/analyse/src/view/components.ts
@@ -88,7 +88,7 @@ export function viewContext(ctrl: AnalyseCtrl, deps?: typeof studyDeps): ViewCon
 }
 
 export function renderMain(
-  { ctrl, playerBars, gaugeOn, gamebookPlayView, needsInnerCoords, hasRelayTour }: ViewContext,
+  { ctrl, playerBars, gaugeOn, gamebookPlayView, needsInnerCoords, hasRelayTour, relay }: ViewContext,
   kids: VNodeKids,
 ): VNode {
   const isRelay = defined(ctrl.study?.relay);
@@ -101,7 +101,7 @@ export function renderMain(
           forceInnerCoords(ctrl, needsInnerCoords);
           if (!!playerBars !== document.body.classList.contains('header-margin'))
             $('body').toggleClass('header-margin', !!playerBars);
-          !hasRelayTour && makeChatEl(ctrl, c => elm.appendChild(c));
+          !relay && makeChatEl(ctrl, c => elm.appendChild(c));
           gridHacks.start(elm);
         },
         update(_, _2) {
diff --git a/ui/lib/src/chat/ctrl.ts b/ui/lib/src/chat/ctrl.ts
index 2e523ed7a6b88..b014b48fb1ef2 100644
--- a/ui/lib/src/chat/ctrl.ts
+++ b/ui/lib/src/chat/ctrl.ts
@@ -13,8 +13,8 @@ import type {
 import { type PresetCtrl, presetCtrl } from './preset';
 import { noteCtrl } from './note';
 import { moderationCtrl } from './moderation';
-import { prop } from '../common';
-import { storage, type LichessStorage } from '../storage';
+import { prop, type Prop } from '../common';
+import { storedStringProp, storedBooleanProp } from '../storage';
 import { pubsub, type PubsubEvent, type PubsubCallback } from '../pubsub';
 import { alert } from '../dialogs';
 
@@ -23,11 +23,11 @@ export default class ChatCtrl {
   private maxLines = 200;
   private maxLinesDrop = 50; // how many lines to drop at once
   private subs: [PubsubEvent, PubsubCallback][];
+  private storedTabKey: Prop<string> = storedStringProp('chat.tab', 'discussion');
+  private allTabs: Tab[] = [];
 
-  allTabs: Tab[] = [];
+  chatEnabled: Prop<boolean> = storedBooleanProp('chat.enabled', true);
   palantir: ChatPalantir;
-  tabStorage: LichessStorage = storage.make('chat.tab');
-  storedTab: string | null = this.tabStorage.get();
   moderation: ModerationCtrl | undefined;
   note: NoteCtrl | undefined;
   preset: PresetCtrl;
@@ -38,25 +38,25 @@ export default class ChatCtrl {
     readonly redraw: Redraw,
   ) {
     this.data = opts.data;
-    if (!opts.kidMode) this.allTabs.push('discussion');
-    if (opts.noteId) this.allTabs.push('note');
-    if (!opts.kidMode && opts.plugin) this.allTabs.push(opts.plugin.tab.key);
+    if (!opts.kidMode) this.allTabs.push({ key: 'discussion' });
+    if (opts.noteId) this.allTabs.push({ key: 'note' });
+    if (opts.plugin && (opts.plugin.kidSafe || !opts.kidMode)) {
+      opts.plugin.redraw = redraw;
+      this.allTabs.push(opts.plugin);
+    }
     this.palantir = {
       instance: undefined,
       loaded: false,
       enabled: prop(!opts.kidMode && !!this.data.palantir),
     };
-    const noChat = storage.get('nochat');
     this.vm = {
-      tab: this.allTabs.find(tab => tab === this.storedTab) || this.allTabs[0],
-      enabled: opts.alwaysEnabled || !noChat,
       loading: false,
       autofocus: false,
       timeout: opts.timeout,
       writeable: opts.writeable,
       domVersion: 1, // increment to force redraw
     };
-
+    this.setTab();
     this.note = opts.noteId
       ? noteCtrl({
           id: opts.noteId,
@@ -73,9 +73,6 @@ export default class ChatCtrl {
 
     if (opts.kidMode) return;
 
-    /* If discussion is disabled, and we have another chat tab,
-     * then select that tab over discussion */
-    if (this.allTabs.length > 1 && this.vm.tab === 'discussion' && noChat) this.vm.tab = this.allTabs[1];
     this.instanciateModeration();
 
     this.subs = [
@@ -88,8 +85,15 @@ export default class ChatCtrl {
     ];
 
     this.subs.forEach(([eventName, callback]) => pubsub.on(eventName, callback));
+  }
+
+  get isOptional(): boolean {
+    const tabs = this.visibleTabs;
+    return tabs.length === 1 && tabs[0].key === 'discussion';
+  }
 
-    this.emitEnabled();
+  get visibleTabs(): Tab[] {
+    return this.allTabs.filter(x => !x.hidden);
   }
 
   get plugin(): ChatPlugin | undefined {
@@ -178,20 +182,16 @@ export default class ChatCtrl {
     this.subs.forEach(([eventName, callback]) => pubsub.off(eventName, callback));
   };
 
-  private emitEnabled = (): void => pubsub.emit('chat.enabled', this.vm.enabled);
-
-  setTab = (t: Tab): void => {
-    this.vm.tab = t;
+  setTab = (tab: Tab = this.getTab()): Tab => {
     this.vm.autofocus = true;
-    this.tabStorage.set(t);
-    this.redraw();
+    this.storedTabKey(tab.key);
+    return tab;
   };
 
-  setEnabled = (v: boolean): void => {
-    this.vm.enabled = v;
-    this.emitEnabled();
-    if (!v) storage.set('nochat', '1');
-    else storage.remove('nochat');
-    this.redraw();
-  };
+  getTab(): Tab {
+    const tabs = this.visibleTabs;
+    return this.chatEnabled()
+      ? (tabs.find(t => t.key === this.storedTabKey()) ?? tabs[0])
+      : tabs[tabs.length - 1];
+  }
 }
diff --git a/ui/lib/src/chat/discussion.ts b/ui/lib/src/chat/discussion.ts
index 8765b761b26ee..47af6b5d8c92f 100644
--- a/ui/lib/src/chat/discussion.ts
+++ b/ui/lib/src/chat/discussion.ts
@@ -14,7 +14,7 @@ import { alert } from '../dialogs';
 const whisperRegex = /^\/[wW](?:hisper)?\s/;
 
 export default function (ctrl: ChatCtrl): Array<VNode | undefined> {
-  if (!ctrl.vm.enabled) return [];
+  if (!ctrl.chatEnabled()) return [];
   const scrollCb = (vnode: VNode, insert: boolean) => {
       const el = vnode.elm as HTMLElement;
       if (ctrl.data.lines.length > 5) {
diff --git a/ui/lib/src/chat/interfaces.ts b/ui/lib/src/chat/interfaces.ts
index a4d8dd05258fc..a30492a98c998 100644
--- a/ui/lib/src/chat/interfaces.ts
+++ b/ui/lib/src/chat/interfaces.ts
@@ -18,16 +18,16 @@ export interface ChatOpts {
   noteId?: string;
   noteText?: string;
   plugin?: ChatPlugin;
-  alwaysEnabled: boolean;
   kidMode: boolean;
 }
 
-export interface ChatPlugin {
-  tab: {
-    key: string;
-    name: string;
-  };
+export type Tab = { key: string; hidden?: boolean };
+
+export interface ChatPlugin extends Tab {
+  name: string;
   view(): VNode;
+  kidSafe?: boolean; // default false
+  redraw?: () => void; // populated by chat module, not you.
 }
 
 export interface ChatData {
@@ -60,8 +60,6 @@ export interface Permissions {
   shadowban?: boolean;
 }
 
-export type Tab = string;
-
 export interface ChatPalantir {
   instance?: Palantir;
   loaded: boolean;
@@ -69,8 +67,6 @@ export interface ChatPalantir {
 }
 
 export interface ViewModel {
-  tab: Tab;
-  enabled: boolean;
   loading: boolean;
   autofocus: boolean;
   timeout: boolean;
diff --git a/ui/lib/src/chat/view.ts b/ui/lib/src/chat/view.ts
index 9ddc23d91fd24..b60923a1e7a3e 100644
--- a/ui/lib/src/chat/view.ts
+++ b/ui/lib/src/chat/view.ts
@@ -1,6 +1,5 @@
-import { h, type VNode } from 'snabbdom';
 import * as licon from '../licon';
-import { bind } from '../snabbdom';
+import { type VNode, looseH as h, bind } from '../snabbdom';
 import type { Tab, Palantir } from './interfaces';
 import discussionView from './discussion';
 import { noteView } from './note';
@@ -10,7 +9,7 @@ import type ChatCtrl from './ctrl';
 
 export default function (ctrl: ChatCtrl): VNode {
   return h(
-    'section.mchat' + (ctrl.opts.alwaysEnabled ? '' : '.mchat-optional'),
+    'section.mchat' + (ctrl.isOptional ? '.mchat-optional' : ''),
     { class: { 'mchat-mod': !!ctrl.moderation }, hook: { destroy: ctrl.destroy } },
     moderationView(ctrl.moderation) || normalView(ctrl),
   );
@@ -40,17 +39,17 @@ function renderPalantir(ctrl: ChatCtrl) {
 }
 
 function normalView(ctrl: ChatCtrl) {
-  const active = ctrl.vm.tab;
+  const active = ctrl.getTab();
   return [
-    h('div.mchat__tabs.nb_' + ctrl.allTabs.length, { attrs: { role: 'tablist' } }, [
-      ...ctrl.allTabs.map(t => renderTab(ctrl, t, active)),
+    h('div.mchat__tabs.nb_' + ctrl.visibleTabs.length, { attrs: { role: 'tablist' } }, [
+      ...ctrl.visibleTabs.map(t => renderTab(ctrl, t, active)),
       renderPalantir(ctrl),
     ]),
     h(
-      'div.mchat__content.' + active,
-      active === 'note' && ctrl.note
+      'div.mchat__content.' + active.key,
+      active.key === 'note' && ctrl.note
         ? [noteView(ctrl.note, ctrl.vm.autofocus)]
-        : ctrl.plugin && active === ctrl.plugin.tab.key
+        : ctrl.plugin && active.key === ctrl.plugin.key
           ? [ctrl.plugin.view()]
           : discussionView(ctrl),
     ),
@@ -59,42 +58,44 @@ function normalView(ctrl: ChatCtrl) {
 
 const renderTab = (ctrl: ChatCtrl, tab: Tab, active: Tab) =>
   h(
-    'div.mchat__tab.' + tab,
+    'div.mchat__tab.' + tab.key,
     {
       attrs: { role: 'tab' },
-      class: { 'mchat__tab-active': tab === active },
-      hook: bind('click', () => ctrl.setTab(tab)),
+      class: { 'mchat__tab-active': tab.key === active.key },
+      hook: bind('click', e => {
+        if ((e.target as HTMLElement).closest('input,label')) return;
+        ctrl.setTab(tab);
+        if (tab.key === 'discussion') ctrl.chatEnabled(true);
+        ctrl.redraw();
+      }),
     },
     tabName(ctrl, tab),
   );
 
 function tabName(ctrl: ChatCtrl, tab: Tab) {
-  if (tab === 'discussion') {
+  if (tab.key === 'discussion') {
     const id = `chat-toggle-${ctrl.data.id}`;
     return [
       h('span', ctrl.data.name),
-      ctrl.opts.alwaysEnabled
-        ? undefined
-        : h('div.switch', [
-            h(`input#${id}.cmn-toggle.cmn-toggle--subtle`, {
-              attrs: {
-                type: 'checkbox',
-                checked: ctrl.vm.enabled,
-              },
-              hook: bind('change', e => {
-                ctrl.setEnabled((e.target as HTMLInputElement).checked);
-              }),
+      ctrl.isOptional &&
+        h('div.switch', [
+          h(`input#${id}.cmn-toggle.cmn-toggle--subtle`, {
+            attrs: { type: 'checkbox', checked: ctrl.chatEnabled() },
+            hook: bind('change', e => {
+              ctrl.chatEnabled((e.target as HTMLInputElement).checked);
+              ctrl.redraw();
             }),
-            h('label', {
-              attrs: {
-                for: id,
-                title: i18n.site.toggleTheChat,
-              },
-            }),
-          ]),
+          }),
+          h('label', {
+            attrs: {
+              for: id,
+              title: i18n.site.toggleTheChat,
+            },
+          }),
+        ]),
     ];
   }
-  if (tab === 'note') return [h('span', i18n.site.notes)];
-  if (ctrl.plugin && tab === ctrl.plugin.tab.key) return [h('span', ctrl.plugin.tab.name)];
+  if (tab.key === 'note') return [h('span', i18n.site.notes)];
+  if (tab.key === ctrl.plugin?.key) return [h('span', ctrl.plugin.name)];
   return [];
 }
diff --git a/ui/lib/src/miniBoard.ts b/ui/lib/src/miniBoard.ts
index d89783bf5834b..1dd7bb562f840 100644
--- a/ui/lib/src/miniBoard.ts
+++ b/ui/lib/src/miniBoard.ts
@@ -11,7 +11,7 @@ export const initMiniBoard = (node: HTMLElement): void => {
   initMiniBoardWith(node, fen, orientation as Color, lm);
 };
 
-export const initMiniBoardWith = (node: HTMLElement, fen: string, orientation: Color, lm?: string): void => {
+export const initMiniBoardWith = (node: HTMLElement, fen: FEN, orientation: Color, lm?: Uci): void => {
   domData.set(
     node,
     'chessground',
@@ -72,6 +72,8 @@ export const initMiniGame = (node: Element, withCg?: typeof makeChessground): st
   return node.getAttribute('data-live');
 };
 
+export const getChessground = (node: HTMLElement): CgApi => domData.get(node, 'chessground');
+
 export const initMiniGames = (parent?: HTMLElement): void => {
   const nodes = Array.from((parent || document).getElementsByClassName('mini-game--init')),
     ids = nodes.map(x => initMiniGame(x)).filter(id => id);
@@ -80,7 +82,7 @@ export const initMiniGames = (parent?: HTMLElement): void => {
 
 export const updateMiniGame = (node: HTMLElement, data: MiniGameUpdateData): void => {
   const lm = data.lm,
-    cg = domData.get(node.querySelector('.cg-wrap')!, 'chessground');
+    cg = getChessground(node.querySelector('.cg-wrap')!);
   if (cg)
     cg.set({
       fen: data.fen,
diff --git a/ui/lib/src/pubsub.ts b/ui/lib/src/pubsub.ts
index 4fa53c07ae97c..dcebc9049c0f9 100644
--- a/ui/lib/src/pubsub.ts
+++ b/ui/lib/src/pubsub.ts
@@ -8,7 +8,6 @@ export type PubsubEvent =
   | 'board.change'
   | 'challenge-app.open'
   | 'chart.panning'
-  | 'chat.enabled'
   | 'chat.permissions'
   | 'chat.resize'
   | 'chat.writeable'
diff --git a/ui/round/src/round.ts b/ui/round/src/round.ts
index 421b159896418..e27f5cf22eacf 100644
--- a/ui/round/src/round.ts
+++ b/ui/round/src/round.ts
@@ -112,7 +112,6 @@ async function boot(
   if (chatOpts) {
     if (data.tournament?.top) {
       chatOpts.plugin = tourStandingCtrl(data.tournament.top, data.tournament.team, i18n.site.standing);
-      chatOpts.alwaysEnabled = true;
     } else if (!data.simul && !data.swiss) {
       chatOpts.preset = getPresetGroup(data);
       chatOpts.enhance = { plies: true };
diff --git a/ui/round/src/tourStanding.ts b/ui/round/src/tourStanding.ts
index a315337668b58..e66ce2d558b66 100644
--- a/ui/round/src/tourStanding.ts
+++ b/ui/round/src/tourStanding.ts
@@ -16,10 +16,8 @@ export const tourStandingCtrl = (
   set(d: TourPlayer[]) {
     players = d;
   },
-  tab: {
-    key: 'tourStanding',
-    name: name,
-  },
+  key: 'tourStanding',
+  name,
   view(): VNode {
     return h('div', { hook: onInsert(_ => site.asset.loadCssPath('round.tour-standing')) }, [
       team ? h('h3.text', { attrs: { 'data-icon': licon.Group } }, team.name) : null,


Reviews:
user: login: ornicar
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-25T10:31:17Z
user: login: jonbgamble
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-25T17:56:50Z
user: login: jonbgamble
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-25T22:37:47Z
user: login: ornicar
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-26T08:08:47Z
user: login: ornicar
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-26T08:37:26Z

Review comments:
diff_hunk: @@ -491,8 +492,8 @@ export default class StudyCtrl {
       this.vm.mode.sticky = false;
       if (!this.vm.behind) this.vm.behind = 1;
       this.vm.chapterId = id;
+      componentCallbacks(id);
       await this.xhrReload();
-      componentCallbacks();
path: ui/analyse/src/study/studyCtrl.ts
user: login: ornicar
body: moving `componentCallbacks()` before `xhrReload()` is likely to break something. Is it necessary, why?
created_at: 2025-03-25T10:31:17Z
author_association: COLLABORATOR
diff_hunk: @@ -491,8 +492,8 @@ export default class StudyCtrl {
       this.vm.mode.sticky = false;
       if (!this.vm.behind) this.vm.behind = 1;
       this.vm.chapterId = id;
+      componentCallbacks(id);
       await this.xhrReload();
-      componentCallbacks();
path: ui/analyse/src/study/studyCtrl.ts
user: login: jonbgamble
body: It allows the redraw of the live board to happen at the same time as the main board. otherwise that redraw lags behind a second for some UI flows as it needs onChapterChange. we could insert extra redraw logic to keep the two in sync for all of the various ways in which a chapter can be selected, but i went with MultiRedraw and onChapterChange to cover all the bases.

What could it break?
created_at: 2025-03-25T17:56:50Z
author_association: CONTRIBUTOR
diff_hunk: @@ -18,16 +18,16 @@ export interface ChatOpts {
   noteId?: string;
   noteText?: string;
   plugin?: ChatPlugin;
-  alwaysEnabled: boolean;
   kidMode: boolean;
 }
 
-export interface ChatPlugin {
-  tab: {
-    key: string;
-    name: string;
-  };
+export type Tab = { key: string; hidden?: boolean };
+
+export interface ChatPlugin extends Tab {
+  name: string;
   view(): VNode;
+  kidSafe?: boolean; // default false
path: ui/lib/src/chat/interfaces.ts
user: login: jonbgamble
body: curious about why we don't (seem to) load tournament standing plugin in kid mode.
created_at: 2025-03-25T22:37:47Z
author_association: CONTRIBUTOR
diff_hunk: @@ -18,16 +18,16 @@ export interface ChatOpts {
   noteId?: string;
   noteText?: string;
   plugin?: ChatPlugin;
-  alwaysEnabled: boolean;
   kidMode: boolean;
 }
 
-export interface ChatPlugin {
-  tab: {
-    key: string;
-    name: string;
-  };
+export type Tab = { key: string; hidden?: boolean };
+
+export interface ChatPlugin extends Tab {
+  name: string;
   view(): VNode;
+  kidSafe?: boolean; // default false
path: ui/lib/src/chat/interfaces.ts
user: login: ornicar
body: No reason I can think of. It's probably an unintended effect of disabling the chat for kids.
created_at: 2025-03-26T08:08:47Z
author_association: COLLABORATOR
diff_hunk: @@ -491,8 +492,8 @@ export default class StudyCtrl {
       this.vm.mode.sticky = false;
       if (!this.vm.behind) this.vm.behind = 1;
       this.vm.chapterId = id;
+      componentCallbacks(id);
       await this.xhrReload();
-      componentCallbacks();
path: ui/analyse/src/study/studyCtrl.ts
user: login: ornicar
body: Well, `componentCallbacks` is supposed to tell components about things having changed. It used to do it **after** things have changed, and now it does it before, so it seemed like it could break something. If said components were reading the data that has changed.
But if it doesn't :shrug: 
created_at: 2025-03-26T08:37:26Z
author_association: COLLABORATOR

PR comments:
user: login: ornicar
created_at: 2025-03-25T09:34:51Z
body: > the orientation is fixed at white

I don't think that's going to fly with the content team
author_association: COLLABORATOR
user: login: ornicar
created_at: 2025-03-25T10:38:24Z
body: This is terrifying like adding extra stories to a building with noodle foundations.

I wish there was a simpler way to handle that exotic use case, but can't come up with one.
author_association: COLLABORATOR
user: login: jonbgamble
created_at: 2025-03-25T18:01:53Z
body: the problem is that chat views have their own patch function. if we want chat plugins with non-trivial functions such as externally triggered redraw, we need something like MultiRedraw.

ultimately, i think chat should just expose a VNode constructor in addition to its controller and get rid of the patching. maybe you could do that if you're itching to refactor things. there's really only two places we have chat right? round & analyse (and all their flavors)
author_association: CONTRIBUTOR
user: login: jonbgamble
created_at: 2025-03-25T18:08:07Z
body: > > the orientation is fixed at white
> 
> I don't think that's going to fly with the content team

Good thing they can look at chat instead.
author_association: CONTRIBUTOR
user: login: jonbgamble
created_at: 2025-03-25T18:14:03Z
body: I kid though, I just thought of it from the standpoint of what Dominik was asking for (truly independent of analysis). And we don't flip the multiboard when the big board is toggled either.

It's a simple thing to listen for pubsub flip but I'm not at all sure a streamer would want that.
author_association: CONTRIBUTOR
user: login: jonbgamble
created_at: 2025-03-25T18:23:49Z
body: An alternative to MultiRedraw would be a global `site.redraw()` function for redraws and a `site.addRedraw()` function to register patches. seems like we wouldn't need to pass them around from controller to controller. i don't like adding things to site but one could arge if anything deserves to be there, it is redraw.
author_association: CONTRIBUTOR
user: login: ornicar
created_at: 2025-03-25T21:46:32Z
body: > there's really only two places we have chat right? round & analyse (and all their flavors)

and swiss, arena, simuls and teams
author_association: COLLABORATOR
user: login: jonbgamble
created_at: 2025-03-25T22:28:31Z
body: 2 is roughly equal to 6.
author_association: CONTRIBUTOR