← back to Marketing Command Center

public/panels/linkedin.js

338 lines

/* global window, document, navigator, localStorage */
window.MCC_PANELS = window.MCC_PANELS || {};
window.MCC_PANELS['linkedin'] = {
  async init(root) {
    const ORIGIN = location.origin;
    const $ = s => root.querySelector(s);
    const esc = s => String(s == null ? '' : s).replace(/[&<>"]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));
    const jget = async u => (await fetch(ORIGIN + u, { credentials: 'same-origin' })).json();
    const msg = (t, err) => { $('#li-msg').textContent = t || ''; $('#li-msg').style.color = err ? '#c0563f' : 'var(--mut)'; };
    const fmtWhen = iso => { const d = new Date(iso); return isNaN(d) ? '' : d.toLocaleString(undefined, { year: 'numeric', month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' }); };
    const deWP = s => String(s || '').replace(/\bwallpapers\b/gi, 'wallcoverings').replace(/\bwallpaper\b/gi, 'wallcovering');

    let TEMPLATES = [];

    async function loadConn() {
      try {
        const c = await jget('/api/linkedin/connection');
        $('#li-banner').innerHTML = c.configured
          ? `Connected · posting to <b>${esc(c.surface)}</b> — posts still require confirm + approve.`
          : 'Draft-only mode — compose here, then Copy + Open LinkedIn to post manually. Live API posting is Phase 2 (needs an approved LinkedIn app + token).';
      } catch { $('#li-banner').textContent = 'Could not load status.'; }
    }
    async function loadTemplates() {
      try { TEMPLATES = (await jget('/api/linkedin/templates')).templates || []; } catch { TEMPLATES = []; }
      $('#li-tpl').innerHTML = '<option value="">— blank —</option>' + TEMPLATES.map(t => `<option value="${esc(t.id)}">${esc(t.name)}</option>`).join('');
    }
    $('#li-tpl').addEventListener('change', e => {
      const t = TEMPLATES.find(x => x.id === e.target.value);
      if (t) { $('#li-text').value = t.body; updateCount(); }
    });

    // char counter + wallpaper-word guard
    function updateCount() {
      const v = $('#li-text').value;
      const n = v.length;
      let note = `${n} chars`;
      if (n < 1300) note += ' · aim 1,300–1,900';
      else if (n > 1900) note += ' · a touch long';
      else note += ' · ✓ sweet spot';
      if (/\bwallpaper/i.test(v)) note += ' · ⚠ “wallpaper” → wallcovering on save';
      $('#li-count').textContent = note;
    }
    $('#li-text').addEventListener('input', updateCount);

    async function loadDrafts() {
      let drafts = [];
      try { drafts = (await jget('/api/linkedin/drafts')).drafts || []; } catch { /* */ }
      $('#li-dcount').textContent = drafts.length;
      $('#li-drafts').innerHTML = drafts.length ? drafts.map(d => `
        <div class="card" style="margin:0 0 10px;padding:12px 14px;">
          <div style="white-space:pre-wrap;font-size:13px;line-height:1.4;max-height:7.5em;overflow:hidden;">${esc(d.text)}</div>
          <div style="display:flex;align-items:center;gap:8px;margin-top:8px;flex-wrap:wrap;">
            <span class="pill" style="font-size:9.5px;">${d.chars} chars</span>
            <span class="when" title="${esc(d.created_at)}" style="font-size:10.5px;color:var(--mut);">🕓 ${esc(fmtWhen(d.created_at))}</span>
            <span style="flex:1;"></span>
            <button class="btn ghost" data-load="${esc(d.id)}" style="font-size:11px;padding:5px 9px;">Edit</button>
            <button class="btn ghost" data-copy="${esc(d.id)}" style="font-size:11px;padding:5px 9px;">Copy</button>
            <button class="btn ghost" data-del="${esc(d.id)}" style="font-size:11px;padding:5px 9px;color:#c0563f;">✕</button>
          </div>
        </div>`).join('') : '<div class="muted">No drafts yet.</div>';
      const byId = Object.fromEntries(drafts.map(d => [d.id, d]));
      $('#li-drafts').querySelectorAll('[data-load]').forEach(b => b.onclick = () => { $('#li-text').value = byId[b.dataset.load].text; updateCount(); window.scrollTo(0, 0); });
      $('#li-drafts').querySelectorAll('[data-copy]').forEach(b => b.onclick = () => copy(byId[b.dataset.copy].text));
      $('#li-drafts').querySelectorAll('[data-del]').forEach(b => b.onclick = async () => {
        await fetch(ORIGIN + '/api/linkedin/draft/' + encodeURIComponent(b.dataset.del), { method: 'DELETE', credentials: 'same-origin' });
        loadDrafts();
      });
    }

    async function copy(t) { try { await navigator.clipboard.writeText(t); msg('Copied to clipboard.'); } catch { msg('Select and ⌘C: ' + t.slice(0, 40)); } }

    $('#li-save').onclick = async () => {
      const text = $('#li-text').value.trim();
      if (!text) { msg('Write something first.', true); return; }
      const hashtags = $('#li-tags').value.split(/[\s,]+/).filter(Boolean);
      const r = await fetch(ORIGIN + '/api/linkedin/draft', { method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'same-origin', body: JSON.stringify({ text, hashtags }) });
      const d = await r.json();
      if (d.error) { msg('✗ ' + d.error, true); return; }
      msg('Draft saved.'); $('#li-text').value = deWP(text); updateCount(); loadDrafts();
    };
    $('#li-copy').onclick = () => { const t = deWP($('#li-text').value.trim()); if (!t) return msg('Nothing to copy.', true); copy(t); };
    $('#li-copytags').onclick = () => { const t = $('#li-tags').value.trim(); if (!t) return msg('No hashtags.', true); copy(t); };
    $('#li-open').onclick = () => {
      const t = deWP($('#li-text').value.trim());
      if (t) copy(t);
      window.open('https://www.linkedin.com/feed/?shareActive=true', '_blank', 'noopener');
      msg('Text copied — paste it into the LinkedIn composer that just opened.');
    };
    $('#li-publish').onclick = async () => {
      const text = deWP($('#li-text').value.trim());
      if (!text) { msg('Write something first.', true); return; }
      if (!confirm('Publish to LinkedIn via the API?\n\nFires only if LinkedIn is connected; otherwise it stages. Proceed?')) return;
      msg('Publishing…');
      const r = await fetch(ORIGIN + '/api/linkedin/publish', { method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'same-origin', body: JSON.stringify({ text, confirm: true, approved: true }) });
      const d = await r.json();
      msg(d.error ? '✗ ' + d.error : (d.posted ? '✓ Posted to LinkedIn.' : (d.message || 'Staged.')), !!d.error);
    };

    // ── Mode toggle: Compose ⇄ Follow list ⇄ Network feed ───────────────────
    const MODES = ['compose', 'follow', 'feed'];
    function setMode(m) {
      if (!MODES.includes(m)) m = 'compose';
      MODES.forEach(x => {
        $('#li-mode-' + x).hidden = x !== m;
        $('#li-mode-' + x + '-btn').className = 'btn ' + (x === m ? 'gold' : 'ghost');
      });
      try { localStorage.setItem('mcc_li_mode', m); } catch {}
      if (m === 'follow') initFollowList();
      if (m === 'feed') { loadCurated(); loadFeed(); }
    }
    $('#li-mode-compose-btn').onclick = () => setMode('compose');
    $('#li-mode-follow-btn').onclick = () => setMode('follow');
    $('#li-mode-feed-btn').onclick = () => setMode('feed');

    // ── Follow list (from public/data/linkedin-followlist.json) ─────────────
    const FL_KEY = 'mcc_li_followed';           // localStorage Set of followed hrefs
    const flDone = () => { try { return new Set(JSON.parse(localStorage.getItem(FL_KEY) || '[]')); } catch { return new Set(); } };
    const flSave = set => { try { localStorage.setItem(FL_KEY, JSON.stringify([...set])); } catch {} };
    let FL = null, flLoaded = false;

    async function initFollowList() {
      if (flLoaded) return; flLoaded = true;
      try { FL = await jget('/data/linkedin-followlist.json'); }
      catch { $('#li-fl-sections').innerHTML = '<div class="card muted">Could not load the follow list.</div>'; return; }
      $('#li-fl-guidance').textContent = FL.guidance || '';
      $('#li-fl-updated').textContent = FL.updated ? `Source: ${esc(FL.source || 'li-clicklist')} · updated ${esc(FL.updated)}` : '';
      $('#li-fl-searches').innerHTML = (FL.searches || []).map(g =>
        `<div style="margin-bottom:10px;"><div class="muted" style="font-size:11.5px;margin-bottom:4px;">${esc(g.group)}</div>` +
        g.links.map(l => `<a class="btn ghost" style="font-size:11px;padding:5px 10px;margin:0 6px 6px 0;display:inline-block;" target="_blank" rel="noopener" href="${esc(l.href)}">${esc(l.label)} ↗</a>`).join('') +
        `</div>`).join('');
      renderFollow();
      $('#li-fl-search').oninput = renderFollow;
      $('#li-fl-reset').onclick = () => { if (confirm('Clear all followed checkmarks on this device?')) { flSave(new Set()); renderFollow(); } };
    }

    function renderFollow() {
      const done = flDone();
      const q = ($('#li-fl-search').value || '').trim().toLowerCase();
      const CAP = 300;   // big sections (e.g. 2.4k brokers) render capped; filter to see the rest
      const html = (FL.sections || []).map(sec => {
        const secDone = sec.people.filter(p => done.has(p.href)).length;
        const matches = sec.people.filter(p => !q || p.name.toLowerCase().includes(q) || (p.sub || '').toLowerCase().includes(q));
        if (!matches.length) return '';
        const shown = matches.slice(0, CAP);
        const more = matches.length - shown.length;
        const rowHTML = shown.map(p => {
          const on = done.has(p.href);
          const badge = p.kind === 'salesnav' ? '<span class="pill" style="font-size:9px;">Sales Nav</span>'
            : p.kind === 'search' ? '<span class="pill" style="font-size:9px;">name search</span>' : '';
          const sub = p.sub ? `<span class="muted" style="font-size:11px;">${esc(p.sub)}</span>` : '';
          return `<label class="li-fl-row" style="display:flex;align-items:center;gap:9px;padding:5px 0;border-bottom:1px solid var(--line,#eee);cursor:pointer;${on ? 'opacity:.5;' : ''}">
              <input type="checkbox" data-href="${esc(p.href)}" ${on ? 'checked' : ''} style="width:16px;height:16px;flex:none;">
              <span style="flex:1;display:flex;flex-direction:column;line-height:1.25;min-width:0;">
                <a href="${esc(p.href)}" target="_blank" rel="noopener" style="${on ? 'text-decoration:line-through;' : ''}" onclick="event.stopPropagation()">${esc(p.name)} ↗</a>
                ${sub}
              </span>
              ${badge}
            </label>`;
        }).join('');
        const moreNote = more > 0 ? `<div class="muted" style="font-size:11px;margin-top:8px;">Showing ${shown.length} of ${matches.length}${q ? ' matches' : ''} — type a name in the filter to narrow.</div>` : '';
        return `<div class="card">
          <h2 style="font-size:14px;">${esc(sec.title)} <span class="pill" data-secpill="${esc(sec.id)}">${secDone}/${sec.people.length}</span></h2>
          <div class="muted" style="font-size:11.5px;margin-bottom:10px;">${esc(sec.note || '')}</div>
          <div>${rowHTML}</div>${moreNote}
        </div>`;
      }).join('');
      $('#li-fl-sections').innerHTML = html || '<div class="card muted">No matches.</div>';
      updateFollowStats(done);
      $('#li-fl-sections').querySelectorAll('input[type=checkbox]').forEach(cb => cb.onchange = () => {
        const set = flDone();
        cb.checked ? set.add(cb.dataset.href) : set.delete(cb.dataset.href);
        flSave(set);
        const row = cb.closest('.li-fl-row');   // in-place update — no full rebuild (2.6k rows)
        if (row) { row.style.opacity = cb.checked ? '.5' : ''; const a = row.querySelector('a'); if (a) a.style.textDecoration = cb.checked ? 'line-through' : ''; }
        updateFollowStats(set);
      });
    }

    function updateFollowStats(done) {
      let total = 0, doneCount = 0;
      (FL.sections || []).forEach(sec => {
        let sd = 0; sec.people.forEach(p => { if (done.has(p.href)) sd++; });
        total += sec.people.length; doneCount += sd;
        const pill = root.querySelector(`[data-secpill="${sec.id}"]`); if (pill) pill.textContent = `${sd}/${sec.people.length}`;
      });
      $('#li-fl-progress').textContent = `${doneCount} / ${total} followed`;
      $('#li-fl-bar').style.width = total ? (100 * doneCount / total).toFixed(1) + '%' : '0';
    }

    // ── Curated amplify board (the working populate path) ───────────────────
    const curMsg = (t, err) => { const el = $('#li-cur-msg'); if (el) { el.textContent = t || ''; el.style.color = err ? '#c0563f' : 'var(--mut)'; } };
    async function loadCurated() {
      let c; try { c = await jget('/api/linkedin/feed/curated'); } catch { return; }
      renderCurated(c.items || []);
    }
    function renderCurated(items) {
      const wrap = $('#li-curated-cards');
      if (!items.length) { wrap.innerHTML = '<div class="card muted">No posts on the board yet — paste a LinkedIn post URL above.</div>'; return; }
      wrap.innerHTML = items.map(it => {
        const dl = it.videoUrl
          ? `<button class="btn ghost" data-cdl="${esc(it.videoUrl)}" data-name="${esc((it.title || 'LinkedIn') + ' — amplify video')}">⬇ Download to assets</button>`
          : '<span class="muted" style="font-size:11px;">(no public video to download)</span>';
        return `<div class="card">
          <div style="display:flex;justify-content:space-between;align-items:center;gap:8px;flex-wrap:wrap;">
            <b>${esc(it.title || 'LinkedIn post')}</b>
            <span class="muted" style="font-size:11px;">added ${esc(fmtWhen(it.addedAt))}</span>
          </div>
          ${it.note ? `<div class="muted" style="font-size:12px;margin:4px 0;">↳ ${esc(it.note)}</div>` : ''}
          <iframe src="${esc(it.embedUrl)}" style="width:100%;height:480px;border:1px solid var(--line,#e6e6e6);border-radius:8px;margin:8px 0;" frameborder="0" allowfullscreen title="Embedded LinkedIn post" loading="lazy"></iframe>
          <div class="row" style="gap:8px;align-items:center;flex-wrap:wrap;">
            <a class="btn" href="${esc(it.permalink)}" target="_blank" rel="noopener noreferrer">Open ↗</a>
            ${dl}
            <button class="btn gated" data-creshare="${esc(it.urn)}" data-cnote="${esc(it.note || '')}">↻ Reshare from DW Page…</button>
            <span style="flex:1;"></span>
            <button class="btn ghost" data-cdel="${esc(it.id)}" style="font-size:11px;">Remove</button>
          </div></div>`;
      }).join('');
      wrap.querySelectorAll('[data-cdl]').forEach(b => b.onclick = async () => {
        b.disabled = true; const old = b.textContent; b.textContent = 'Downloading…';
        try {
          const r = await fetch(ORIGIN + '/api/linkedin/feed/download', { method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'same-origin', body: JSON.stringify({ src: b.dataset.cdl, name: b.dataset.name }) });
          const d = await r.json();
          if (d.ok) { b.textContent = '✓ In Asset Library'; curMsg('Saved ' + Math.round((d.asset.size || 0) / 1024) + ' KB to the Asset Library.'); }
          else { b.textContent = old; b.disabled = false; curMsg('Download failed: ' + (d.error || 'error'), true); }
        } catch (e) { b.textContent = old; b.disabled = false; curMsg('Download failed: ' + e.message, true); }
      });
      wrap.querySelectorAll('[data-creshare]').forEach(b => b.onclick = async () => {
        const note = window.prompt('Comment for the reshare (optional) — OK to post from the DW Page:', b.dataset.cnote || '');
        if (note === null) return;
        if (!window.confirm('Reshare this post live from the DW LinkedIn Page now?')) return;
        b.disabled = true; const old = b.textContent; b.textContent = 'Resharing…';
        try {
          const r = await fetch(ORIGIN + '/api/linkedin/feed/reshare', { method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'same-origin', body: JSON.stringify({ urn: b.dataset.creshare, commentary: deWP(note), confirm: true, approved: true }) });
          const d = await r.json();
          if (d.posted) { b.textContent = '✓ Reshared'; curMsg('Reshared from the DW Page (id ' + (d.id || '?') + ').'); }
          else if (d.staged) { b.textContent = old; b.disabled = false; curMsg(d.message, true); }
          else { b.textContent = old; b.disabled = false; curMsg('Reshare not posted: ' + (d.error || 'error') + '. (LinkedIn can restrict third-party reshares.)', true); }
        } catch (e) { b.textContent = old; b.disabled = false; curMsg('Reshare failed: ' + e.message, true); }
      });
      wrap.querySelectorAll('[data-cdel]').forEach(b => b.onclick = async () => {
        if (!window.confirm('Remove this post from the board?')) return;
        await fetch(ORIGIN + '/api/linkedin/feed/curated/' + encodeURIComponent(b.dataset.cdel), { method: 'DELETE', credentials: 'same-origin' });
        loadCurated();
      });
    }
    $('#li-cur-add').onclick = async () => {
      const url = $('#li-cur-url').value.trim();
      if (!url) return curMsg('Paste a LinkedIn post URL first.', true);
      const btn = $('#li-cur-add'); btn.disabled = true; curMsg('Adding…');
      try {
        const r = await fetch(ORIGIN + '/api/linkedin/feed/curated', { method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'same-origin', body: JSON.stringify({ url, note: $('#li-cur-note').value.trim() }) });
        const d = await r.json();
        if (d.ok) { $('#li-cur-url').value = ''; $('#li-cur-note').value = ''; curMsg('Added to the board.'); loadCurated(); }
        else curMsg(d.error || 'Could not add that URL.', true);
      } catch (e) { curMsg('Add failed: ' + e.message, true); }
      btn.disabled = false;
    };
    $('#li-cur-url').addEventListener('keydown', e => { if (e.key === 'Enter') $('#li-cur-add').click(); });

    // ── Network feed (harvested from Steve's own LinkedIn via openclaw) ──────
    const feedStatus = (t, err) => { const el = $('#li-feed-status'); if (el) { el.textContent = t || ''; el.style.color = err ? '#c0563f' : 'var(--mut)'; } };
    async function loadFeed() {
      const all = $('#li-feed-all') && $('#li-feed-all').checked ? '?all=1' : '';
      let f; try { f = await jget('/api/linkedin/feed' + all); } catch { feedStatus('Could not load the feed.', true); return; }
      $('#li-feed-count').textContent = f.count || 0;
      if (f.harvestedAt) feedStatus('Harvested ' + fmtWhen(f.harvestedAt) + ' — ' + (f.videos || 0) + ' video / ' + (f.total || 0) + ' posts. licdn video URLs expire; re-refresh if a download 502s.');
      renderFeed(f.posts || []);
    }
    function renderFeed(posts) {
      const wrap = $('#li-feed-cards');
      if (!posts.length) { wrap.innerHTML = '<div class="card muted">No posts harvested yet — click <b>Refresh from LinkedIn</b>.</div>'; return; }
      wrap.innerHTML = posts.map(p => {
        const poster = p.video && p.video.poster ? `<img src="${esc(p.video.poster)}" alt="" style="width:120px;height:auto;border-radius:8px;object-fit:cover;flex:none;">` : '';
        const vid = p.isVideo ? '<span class="pill" style="font-size:9px;">▶ video</span>' : '';
        const dl = p.isVideo && p.video && p.video.best
          ? `<button class="btn ghost" data-dl="${esc(p.video.best)}" data-name="${esc((p.author || 'LinkedIn') + ' — network video')}">⬇ Download to assets</button>` : '';
        return `<div class="card" style="display:flex;gap:12px;">
          ${poster}
          <div style="flex:1;min-width:0;">
            <div style="display:flex;align-items:center;gap:8px;"><b>${esc(p.author || 'Unknown')}</b> ${vid}</div>
            ${p.sub ? `<div class="muted" style="font-size:11px;">${esc(p.sub)}</div>` : ''}
            <div style="font-size:12.5px;margin:6px 0;white-space:pre-wrap;">${esc((p.text || '').slice(0, 280))}${(p.text || '').length > 280 ? '…' : ''}</div>
            <div class="row" style="gap:8px;align-items:center;flex-wrap:wrap;">
              <a class="btn" href="${esc(p.permalink)}" target="_blank" rel="noopener noreferrer">View on LinkedIn ↗</a>
              ${dl}
              <button class="btn gated" data-reshare="${esc(p.urn)}">↻ Reshare from DW Page…</button>
            </div>
          </div></div>`;
      }).join('');
      wrap.querySelectorAll('[data-dl]').forEach(b => b.onclick = async () => {
        b.disabled = true; const old = b.textContent; b.textContent = 'Downloading…';
        try {
          const r = await fetch(ORIGIN + '/api/linkedin/feed/download', { method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'same-origin', body: JSON.stringify({ src: b.dataset.dl, name: b.dataset.name }) });
          const d = await r.json();
          if (d.ok) { b.textContent = '✓ In Asset Library'; feedStatus('Saved ' + Math.round((d.asset.size || 0) / 1024) + ' KB to the Asset Library.'); }
          else { b.textContent = old; b.disabled = false; feedStatus('Download failed: ' + (d.error || 'error'), true); }
        } catch (e) { b.textContent = old; b.disabled = false; feedStatus('Download failed: ' + e.message, true); }
      });
      wrap.querySelectorAll('[data-reshare]').forEach(b => b.onclick = async () => {
        const note = window.prompt('Add your comment for the reshare (optional) — then OK to post from the DW Page:', '');
        if (note === null) return; // cancelled
        if (!window.confirm('Reshare this post live from the DW LinkedIn Page now?')) return;
        b.disabled = true; const old = b.textContent; b.textContent = 'Resharing…';
        try {
          const r = await fetch(ORIGIN + '/api/linkedin/feed/reshare', { method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'same-origin', body: JSON.stringify({ urn: b.dataset.reshare, commentary: deWP(note), confirm: true, approved: true }) });
          const d = await r.json();
          if (d.posted) { b.textContent = '✓ Reshared'; feedStatus('Reshared from the DW Page (id ' + (d.id || '?') + ').'); }
          else if (d.staged) { b.textContent = old; b.disabled = false; feedStatus(d.message, true); }
          else { b.textContent = old; b.disabled = false; feedStatus('Reshare not posted: ' + (d.error || 'error') + '. (LinkedIn can restrict third-party reshares.)', true); }
        } catch (e) { b.textContent = old; b.disabled = false; feedStatus('Reshare failed: ' + e.message, true); }
      });
    }
    $('#li-feed-all') && ($('#li-feed-all').onchange = loadFeed);
    $('#li-feed-refresh').onclick = async () => {
      const btn = $('#li-feed-refresh'); btn.disabled = true; feedStatus('Starting openclaw harvest of your feed…');
      try {
        const r = await fetch(ORIGIN + '/api/linkedin/feed/harvest', { method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'same-origin', body: JSON.stringify({}) });
        const d = await r.json();
        if (!d.started) { feedStatus(d.message || 'A harvest is already running.', true); btn.disabled = false; return; }
      } catch (e) { feedStatus('Could not start harvest: ' + e.message, true); btn.disabled = false; return; }
      // poll status until the child process exits
      const poll = setInterval(async () => {
        let s; try { s = await jget('/api/linkedin/feed/harvest/status'); } catch { return; }
        const tail = (s.log || '').split('\n').filter(Boolean).pop() || '';
        if (s.running) { feedStatus('Harvesting… ' + tail); return; }
        clearInterval(poll); btn.disabled = false;
        if (s.error || s.code) feedStatus('Harvest finished with an issue — ' + (s.error || tail || ('exit ' + s.code)), true);
        else feedStatus('Harvest complete. ' + tail);
        loadFeed();
      }, 2500);
    };

    await loadConn(); await loadTemplates(); await loadDrafts(); updateCount();
    // Restore last mode (default compose).
    setMode((() => { try { return localStorage.getItem('mcc_li_mode') || 'compose'; } catch { return 'compose'; } })());
  },
};