'use strict';
// ═══════════════════════════════════════════════
// js/contacts.js — Contacts page: identity, buddy UX, journal viewer
//
// CONNECTION MODEL:
//   • Each user has a permanent ECDH keypair. Their identity code is their
//     public key. Contacts are identified by public key, not any server ID.
//   • Auto-connect uses a WebSocket relay on forestorers-signal.onrender.com
//     to broker the connection. All data is AES-256-GCM encrypted before
//     leaving your device — the relay sees only opaque ciphertext.
//   • Journal data is encrypted end-to-end using ECDH-derived keys.
// ═══════════════════════════════════════════════

let _viewingContactKey = null;

// Get a peer's journal — ephemeral (live, in-memory) takes priority over saved copy
function getPeerJournal(pubKeyB64) {
  const ephemeral = getEphemeralJournal(pubKeyB64);
  if (ephemeral) return ephemeral;
  const c = getContacts().find(x => x.pubKeyB64 === pubKeyB64);
  return c?.journal || null;
}

// Called by p2p.js when a journal or status update arrives
let _contactsListRaf = 0;
function renderContactsList() {
  cancelAnimationFrame(_contactsListRaf);
  _contactsListRaf = requestAnimationFrame(_doRenderContactsList);
}
function _doRenderContactsList() {
  _contactsListRaf = 0;
  const el = document.getElementById('contacts-list');
  if (!el) return;
  el.innerHTML = renderContactsListHtml(getContacts());
  updatePeerStatus();
}


function refreshPeerJournalPanel(pubKeyB64) {
  if (_viewingContactKey !== pubKeyB64) return;
  const el = document.getElementById('contact-journal-panel');
  if (el) el.innerHTML = renderContactJournalPanel(pubKeyB64);
}

function refreshChatPanel(pubKeyB64) {
  if (_viewingContactKey !== pubKeyB64) return;
  const el = document.getElementById('peer-chat-messages');
  if (!el) return;
  el.innerHTML = renderChatMessages(pubKeyB64);
  el.scrollTop = el.scrollHeight;
}

// Update delivery status of an outgoing message already in the localStorage history.
function _updateChatMsgStatus(pubKeyB64, id, patch) {
  const hist = getChatHistory(pubKeyB64);
  const idx  = hist.findIndex(m => m.id === id);
  if (idx === -1) return;
  Object.assign(hist[idx], patch);
  saveChatHistory(pubKeyB64, hist);
  refreshChatPanel(pubKeyB64);
  if (typeof refreshMessenger === 'function') refreshMessenger(pubKeyB64);
}

// Called from p2p.js when relay events arrive (delivered, expired, received).
function _chatNotify(msgId, event, fromPubKeyB64) {
  const msgrOpen = typeof _msgrOpenKey === 'function' ? _msgrOpenKey() : null;
  if (event === 'received' && fromPubKeyB64) {
    if (_chatPopupKey === fromPubKeyB64 || msgrOpen === fromPubKeyB64) {
      // A chat view is open for this sender — clear unread immediately, no toast
      if (typeof clearChatUnread === 'function') clearChatUnread(fromPubKeyB64);
      if (typeof updatePeerStatus === 'function') updatePeerStatus();
      refreshChatPopup(fromPubKeyB64);
    } else {
      // No chat view open — show toast, leave unread badge alone
      if (typeof toast === 'function') {
        const contacts = getContacts();
        const c = contacts.find(x => x.pubKeyB64 === fromPubKeyB64);
        toast(`${c?.nickname || 'Buddy'}: new message`, false, true); // _noMirror: orange badge is sufficient
      }
      if (typeof updatePeerStatus === 'function') updatePeerStatus();
    }
    if (typeof refreshMessenger === 'function') refreshMessenger(fromPubKeyB64);
  } else if (event === 'delivered' || event === 'expired') {
    const key = _chatPopupKey || msgrOpen;
    if (!key) return;
    _updateChatMsgStatus(key, msgId, event === 'delivered' ? { delivered: true } : { failed: true });
    refreshChatPopup(key);
    if (typeof refreshMessenger === 'function') refreshMessenger(key);
  }
}

function _fmtTTL(d) {
  if (d === 0) return 'never';
  if (d <= 1/1440 + 0.0001) return '1 min';
  if (d <= 1/24 + 0.0001)   return '1 hour';
  if (d === 1)   return '1 day';
  if (d < 1)     return `${Math.round(d * 24)} hours`;
  if (d === 365) return '1 year';
  return `${d} days`;
}

function _fmtTimeLeft(daysLeft) {
  if (daysLeft <= 0) return 'expiring now';
  if (daysLeft < 1/1440) return '<1 min left';
  if (daysLeft < 1/24) return `${Math.max(1, Math.round(daysLeft * 1440))} min left`;
  if (daysLeft < 1) return `${Math.max(1, Math.round(daysLeft * 24))}h left`;
  return `${Math.round(daysLeft)}d left`;
}

// ─── Permission matrix helpers ────────────────────────────────────────────────

// Returns true when Trusted tier has fewer permissions than Buddy tier on any cell
function _permTierWarning(bp, tp) {
  return (bp.timerStatus    && !tp.timerStatus)    ||
         (bp.viewLatestImg  && !tp.viewLatestImg)  ||
         (bp.cacheLatestImg && !tp.cacheLatestImg) ||
         (bp.viewAllImgs    && !tp.viewAllImgs)    ||
         (bp.cacheAllImgs   && !tp.cacheAllImgs);
}

// Live dependency enforcement for one tier's matrix row + updates tier-warning div
function _updatePermMatrix(tier) {
  const viewLatest    = document.getElementById(`pm-${tier}-viewlatest`)?.checked  ?? false;
  const viewAll       = document.getElementById(`pm-${tier}-viewall`)?.checked      ?? false;
  const cacheLatestEl = document.getElementById(`pm-${tier}-cachelatest`);
  const viewAllEl     = document.getElementById(`pm-${tier}-viewall`);
  const cacheAllEl    = document.getElementById(`pm-${tier}-cacheall`);
  if (cacheLatestEl) { cacheLatestEl.disabled = !viewLatest; if (!viewLatest) cacheLatestEl.checked = false; }
  if (viewAllEl)     { viewAllEl.disabled     = !viewLatest; if (!viewLatest) viewAllEl.checked     = false; }
  if (cacheAllEl)    { cacheAllEl.disabled    = !(viewLatest && viewAll); if (!(viewLatest && viewAll)) cacheAllEl.checked = false; }
  // Refresh tier-warning
  const bp = {
    timerStatus:    document.getElementById('pm-buddy-timer')?.checked          ?? false,
    viewLatestImg:  document.getElementById('pm-buddy-viewlatest')?.checked     ?? false,
    cacheLatestImg: document.getElementById('pm-buddy-cachelatest')?.checked    ?? false,
    viewAllImgs:    document.getElementById('pm-buddy-viewall')?.checked        ?? false,
    cacheAllImgs:   document.getElementById('pm-buddy-cacheall')?.checked       ?? false,
    noWatermark:    document.getElementById('pm-buddy-nowatermark')?.checked    ?? false,
  };
  const tp = {
    timerStatus:    document.getElementById('pm-trusted-timer')?.checked        ?? false,
    viewLatestImg:  document.getElementById('pm-trusted-viewlatest')?.checked   ?? false,
    cacheLatestImg: document.getElementById('pm-trusted-cachelatest')?.checked  ?? false,
    viewAllImgs:    document.getElementById('pm-trusted-viewall')?.checked      ?? false,
    cacheAllImgs:   document.getElementById('pm-trusted-cacheall')?.checked     ?? false,
    noWatermark:    document.getElementById('pm-trusted-nowatermark')?.checked  ?? false,
  };
  const warn = document.getElementById('perm-tier-warning');
  if (warn) warn.style.display = _permTierWarning(bp, tp) ? '' : 'none';
}

// ─── Permission presets ───────────────────────────────────────────────────────
// Three plain choices per tier, backed by the same checkboxes the advanced
// matrix drives. Caching and no-watermark stay off in every preset; those are
// deliberate advanced opt-ins. Photos are never shared until you pick one.
function _permPresetOf(o) {
  if (!o || !o.viewLatestImg) return 'stats';
  return o.viewAllImgs ? 'all' : 'latest';
}

function _permPresetChips(tier, active) {
  const opt = (key, label) =>
    `<button class="perm-chip${active === key ? ' active' : ''}" data-preset="${key}" data-action="applyPermPreset" data-a0="${tier}" data-a1="${key}">${label}</button>`;
  return `${opt('stats', 'Stats only')}${opt('latest', 'Latest photo')}${opt('all', 'All photos')}`;
}

function _refreshPresetChips() {
  ['buddy', 'trusted'].forEach(tier => {
    const active = _permPresetOf({
      viewLatestImg: document.getElementById(`pm-${tier}-viewlatest`)?.checked,
      viewAllImgs:   document.getElementById(`pm-${tier}-viewall`)?.checked,
    });
    document.querySelectorAll(`#perm-presets-${tier} .perm-chip`).forEach(chip =>
      chip.classList.toggle('active', chip.dataset.preset === active));
  });
}

function applyPermPreset(tier, preset) {
  const set = (field, val) => { const el = document.getElementById(`pm-${tier}-${field}`); if (el) el.checked = val; };
  const viewLatest = preset !== 'stats';
  const viewAll    = preset === 'all';
  set('viewlatest', viewLatest);
  set('viewall',    viewAll);
  set('cachelatest', false);
  set('cacheall',    false);
  if (typeof _updatePermMatrix === 'function') _updatePermMatrix(tier);
  _refreshPresetChips();
  if (typeof saveBuddyPermissionsUI === 'function') saveBuddyPermissionsUI();
}

// Share identity code via the OS share sheet when available, else copy.
async function shareMyCode(code) {
  if (navigator.share) {
    try {
      await navigator.share({ title: 'My Forestorers buddy code', text: code });
      return;
    } catch (e) { if (e.name === 'AbortError') return; /* fall through to copy */ }
  }
  if (typeof copyToClipboard === 'function') copyToClipboard(code);
}

// ─── Main page render ─────────────────────────────────────────────────────────

async function renderContactsPage() {
  const el = document.getElementById('contacts-content');
  if (!el) return;
  try {
    await _renderContactsPageInner(el);
    // Hydrate the in-page toggle bank from current state so it doesn't flash
    // OFF before the next event tick.
    if (typeof updateNetworkToggleUI === 'function') updateNetworkToggleUI();
    if (typeof _renderIncomingRequests === 'function') _renderIncomingRequests();
  } catch(e) {
    console.error('[contacts] renderContactsPage error:', e);
    el.innerHTML = `<div class="empty" style="padding:2rem"><p>Something went wrong loading buddies.<br><button class="btn btn-primary btn-sm" style="margin-top:8px" onclick="renderContactsPage()">Retry</button></p></div>`;
  }
}
async function _renderContactsPageInner(el) {
  const identity = await getIdentity();
  const contacts = getContacts();
  const myCode = identity ? makeIdentityCode(identity.pubKeyB64) : '-';

  const encEnabled = typeof idbMetaGet === 'function' ? await idbMetaGet('encryptionEnabled') : false;
  const iAmEncrypted = typeof getSessionKey === 'function' && !!getSessionKey();

  const vanishSettings = ((typeof getVanishSettings === 'function') ? getVanishSettings() : null) || { ttl: 90, trustedTTL: 0 };
  const vanishTTL = vanishSettings.ttl ?? 90;
  const trustedTTL = vanishSettings.trustedTTL ?? 0;
  const ttlOptions = [1/1440, 1/24, 1, 10, 30, 90, 365, 0];
  const trustedTTLOptions = [0, 1/1440, 1/24, 1, 10, 30, 90, 365];

  const bPerms = ((typeof getBuddyPermissions === 'function') ? getBuddyPermissions() : null) || { v:3, buddy:{}, trusted:{}, showBuddyTimerStatus: true, requireMutualPhoto: true };
  const bp = bPerms.buddy   || {};
  const tp = bPerms.trusted || {};
  const showBuddyTimerStatus = !!bPerms.showBuddyTimerStatus;
  const requireMutualPhoto   = bPerms.requireMutualPhoto !== false;
  const noCacheIncoming      = !!bPerms.noCacheIncoming;
  const shareBuddyList       = (bPerms.buddy?.shareBuddyList !== false) && (bPerms.trusted?.shareBuddyList !== false);
  const hideMeFromBuddyLists = !!bPerms.hideMeFromBuddyLists;
  const connectOpen = localStorage.getItem('frj_panel_connect') === '1';
  const permsOpen   = localStorage.getItem('frj_panel_perms')   === '1';
  const netToggles = (typeof getNetworkToggles === 'function') ? getNetworkToggles() : { network: false, buddies: false };

  // Encryption gate content for gated panels
  const _encGate = !encEnabled
    ? `<div class="pb" style="padding:18px">
        <div style="background:var(--yellowl);border:1px solid var(--yellowb);border-radius:6px;padding:12px 14px">
          <div style="font-weight:600;font-size:13px;color:var(--text);margin-bottom:6px">Encryption required</div>
          <p style="font-size:12px;color:var(--muted);margin:0 0 10px 0;line-height:1.5">Buddy features require encryption to be set up. This ensures your data, and any photos cached on buddy devices, is always protected.</p>
          <button class="btn btn-primary btn-sm" data-action="showMigrationModal">Set up encryption</button>
        </div>
      </div>`
    : !iAmEncrypted
    ? `<div class="pb" style="padding:18px">
        <div style="background:var(--yellowl);border:1px solid var(--yellowb);border-radius:6px;padding:12px 14px">
          <div style="font-weight:600;font-size:13px;color:var(--text);margin-bottom:6px">Unlock required</div>
          <p style="font-size:12px;color:var(--muted);margin:0 0 10px 0;line-height:1.5">Your data is encrypted. Enter your password to use buddy features.</p>
          <button class="btn btn-primary btn-sm" data-action="showUnlockModal">Unlock</button>
        </div>
      </div>`
    : null;

  el.innerHTML = `
    <div>
        <!-- Persistent banner for pending incoming buddy requests. Sits ABOVE
             My Buddies so it's visible regardless of presence/network state.
             Populated by _renderIncomingRequests() in buddynet.js. -->
        <div id="contacts-incoming-banner"></div>
        <!-- My Buddies list -->
        <div class="panel" style="margin-bottom:1rem">
          <div class="ph">
            <h2>My Buddies <span class="sb-badge">${contacts.filter(c => !c.removedAt && !c.forgottenAt).length}</span></h2>
          </div>
          ${!netToggles.network || !netToggles.buddies ? `
          <div style="margin:0 14px 12px;padding:10px 12px;background:rgba(245,158,11,.08);border:1px solid rgba(245,158,11,.35);border-radius:6px;display:flex;align-items:flex-start;gap:10px">
            <span style="font-size:18px;line-height:1;flex-shrink:0">📡</span>
            <div>
              <div style="font-size:12px;font-weight:600;color:var(--amber,#f59e0b);margin-bottom:3px">Buddies is off — you won't sync</div>
              <div style="font-size:11px;color:var(--muted2);line-height:1.5;margin-bottom:8px">Turn on <strong>Network</strong> then <strong>Buddies</strong> in the sidebar to sync journals and photos with your buddies in real time.</div>
              <button class="btn btn-sm" style="font-size:11px;padding:3px 10px;border-color:rgba(245,158,11,.5);color:var(--amber,#f59e0b)" data-action="toggleBuddiesBtn">Turn on Buddies</button>
            </div>
          </div>` : ''}
          <div id="contacts-list" style="display:grid;grid-template-columns:repeat(auto-fill,minmax(240px,1fr));gap:8px">
            ${renderContactsListHtml(contacts)}
          </div>
        </div>

        <!-- Restorer Network -- only visible when Public is on. Public is the
             user's one-time choice in the sidebar toggle; we don't re-nudge them
             from inside the contacts page. If they've turned it off, this whole
             panel goes away and the buddy list is the entire page. -->
        <div class="panel" style="margin-bottom:1rem${netToggles.public ? '' : ';display:none'}">
          <div class="ph"><h2>Restorer Network</h2></div>
          <div class="pb" style="padding:10px 12px">
            <div id="contacts-incoming-section"></div>
            <div id="contacts-pending-section"></div>
            <div id="contacts-restorers-online-section" style="display:none">
              <div style="font-family:var(--mono);font-size:11px;font-weight:700;letter-spacing:.06em;text-transform:uppercase;color:var(--muted2);margin-bottom:6px">Online now</div>
              <div id="contacts-restorers-online-row" style="display:flex;flex-direction:column;gap:3px"></div>
            </div>
            <div id="contacts-restorers-recent-section" style="display:none">
              <div style="font-family:var(--mono);font-size:11px;font-weight:700;letter-spacing:.06em;text-transform:uppercase;color:var(--muted2);margin:10px 0 6px">Recently active</div>
              <div id="contacts-restorers-recent-row" style="display:flex;flex-direction:column;gap:3px"></div>
            </div>
            <div id="contacts-restorers-empty" style="display:none;padding:14px 12px;text-align:center;background:var(--surface2);border:1px dashed var(--border2);border-radius:6px">
              <div style="font-size:22px;margin-bottom:4px">🌿</div>
              <p style="font-size:13px;color:var(--muted2);margin:0;line-height:1.5">No restorers have been recently online.</p>
              <p style="font-size:11px;color:var(--muted2);margin:4px 0 0;line-height:1.5">You're listed here for others to find. Check back later.</p>
            </div>
          </div>
        </div>

        <!-- Buddy Permissions (collapsed by default) -->
        <div class="panel${permsOpen ? '' : ' collapsed'}" id="perms-panel" style="margin-bottom:1rem">
          <div class="ph" style="cursor:pointer" data-action="toggleContactPanel" data-a0="perms-panel" data-a1="frj_panel_perms">
            <h2>Buddy Permissions</h2>
            <span style="margin-left:auto;font-size:11px;color:var(--muted2)">${permsOpen ? '▲' : '▼'}</span>
          </div>
          ${_encGate || `<div class="pb" style="font-size:13px;color:var(--muted);line-height:1.7">

            <!-- Intro -->
            <p style="font-size:12px;color:var(--muted2);margin:0 0 8px 0;padding:9px 11px;background:var(--surface2);border:1px solid var(--border2);border-radius:5px;line-height:1.6">
              All data shared with buddies is <strong style="color:var(--green)">end-to-end encrypted</strong>. Cached photos live on their device, protected by their own key. The server never sees them. Without caching, photos are only visible while you're both online.
            </p>
            <p style="font-size:12px;color:var(--muted2);margin:0 0 14px 0;padding:9px 11px;background:var(--surface2);border:1px solid var(--border2);border-radius:5px;line-height:1.6">
              <strong>Always shared with all buddies (no permission needed):</strong> tugging &amp; retaining averages (shown as weekly summaries, last 2 weeks on timeline), CI progress, devices used, achievements, and display name. <strong>Photos only</strong> require your permission below.
            </p>

            <!-- Permission matrix -->
            <div style="overflow-x:auto;margin:0 -4px 4px;-webkit-overflow-scrolling:touch">
              <table style="border-collapse:collapse;min-width:420px;width:100%">
                <thead>
                  <tr>
                    <th style="width:68px;padding:0 8px 10px 0;text-align:left;vertical-align:bottom;border-bottom:2px solid var(--border2)"></th>
                    <th style="padding:4px 6px 10px;text-align:center;vertical-align:bottom;border-bottom:2px solid var(--border2);font-size:11px;font-weight:600;color:var(--muted2);line-height:1.35" title="See your live tugging or retaining timer status while connected">Tugging /<br>Retaining<br>Status</th>
                    <th style="padding:4px 6px 10px;text-align:center;vertical-align:bottom;border-bottom:2px solid var(--border2);font-size:11px;font-weight:600;color:var(--muted2);line-height:1.35" title="View your most recent progress photo while you are both online">View<br>Latest<br>Photo</th>
                    <th style="padding:4px 6px 10px;text-align:center;vertical-align:bottom;border-bottom:2px solid var(--border2);font-size:11px;font-weight:600;color:var(--muted2);line-height:1.35" title="Keep an encrypted copy of your latest photo on their device between sessions">Cache<br>Latest<br>Photo</th>
                    <th style="padding:4px 6px 10px;text-align:center;vertical-align:bottom;border-bottom:2px solid var(--border2);font-size:11px;font-weight:600;color:var(--muted2);line-height:1.35" title="Browse all your past progress photos (except private entries) while connected">View<br>All<br>Photos</th>
                    <th style="padding:4px 6px 10px;text-align:center;vertical-align:bottom;border-bottom:2px solid var(--border2);font-size:11px;font-weight:600;color:var(--muted2);line-height:1.35" title="Keep encrypted copies of all your past photos on their device. Persists until you disconnect from them.">Cache<br>All<br>Photos</th>
                    <th style="padding:4px 6px 10px;text-align:center;vertical-align:bottom;border-bottom:2px solid var(--border2);font-size:11px;font-weight:600;color:var(--amber);line-height:1.35" title="Disable all watermarks for this tier. By default, a forensic mark with the recipient's buddy code is baked into every shared photo. Disabling it means you lose the ability to trace a leaked photo back to who received it.">No<br>Water-<br>mark</th>
                  </tr>
                </thead>
                <tbody>
                  <tr>
                    <td style="padding:10px 8px 10px 0;font-family:var(--mono);font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--muted2);white-space:nowrap;border-bottom:1px solid var(--border2)">Buddy</td>
                    <td style="text-align:center;padding:8px 6px;border-bottom:1px solid var(--border2)"><input type="checkbox" id="pm-buddy-timer"         ${bp.timerStatus    ? 'checked' : ''} style="cursor:pointer;width:15px;height:15px"></td>
                    <td style="text-align:center;padding:8px 6px;border-bottom:1px solid var(--border2)"><input type="checkbox" id="pm-buddy-viewlatest"    ${bp.viewLatestImg  ? 'checked' : ''} style="cursor:pointer;width:15px;height:15px"></td>
                    <td style="text-align:center;padding:8px 6px;border-bottom:1px solid var(--border2)"><input type="checkbox" id="pm-buddy-cachelatest"   ${bp.cacheLatestImg ? 'checked' : ''} ${!bp.viewLatestImg ? 'disabled' : ''} style="cursor:pointer;width:15px;height:15px"></td>
                    <td style="text-align:center;padding:8px 6px;border-bottom:1px solid var(--border2)"><input type="checkbox" id="pm-buddy-viewall"        ${bp.viewAllImgs    ? 'checked' : ''} ${!bp.viewLatestImg ? 'disabled' : ''} style="cursor:pointer;width:15px;height:15px"></td>
                    <td style="text-align:center;padding:8px 6px;border-bottom:1px solid var(--border2)"><input type="checkbox" id="pm-buddy-cacheall"       ${bp.cacheAllImgs   ? 'checked' : ''} ${(!bp.viewLatestImg || !bp.viewAllImgs) ? 'disabled' : ''} style="cursor:pointer;width:15px;height:15px"></td>
                    <td style="text-align:center;padding:8px 6px;border-bottom:1px solid var(--border2)"><input type="checkbox" id="pm-buddy-nowatermark"   ${bp.noWatermark    ? 'checked' : ''} style="cursor:pointer;width:15px;height:15px"></td>
                  </tr>
                  <tr>
                    <td style="padding:10px 8px 6px 0;font-family:var(--mono);font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--muted2);white-space:nowrap">Trusted</td>
                    <td style="text-align:center;padding:8px 6px"><input type="checkbox" id="pm-trusted-timer"         ${tp.timerStatus    ? 'checked' : ''} style="cursor:pointer;width:15px;height:15px"></td>
                    <td style="text-align:center;padding:8px 6px"><input type="checkbox" id="pm-trusted-viewlatest"    ${tp.viewLatestImg  ? 'checked' : ''} style="cursor:pointer;width:15px;height:15px"></td>
                    <td style="text-align:center;padding:8px 6px"><input type="checkbox" id="pm-trusted-cachelatest"   ${tp.cacheLatestImg ? 'checked' : ''} ${!tp.viewLatestImg ? 'disabled' : ''} style="cursor:pointer;width:15px;height:15px"></td>
                    <td style="text-align:center;padding:8px 6px"><input type="checkbox" id="pm-trusted-viewall"       ${tp.viewAllImgs    ? 'checked' : ''} ${!tp.viewLatestImg ? 'disabled' : ''} style="cursor:pointer;width:15px;height:15px"></td>
                    <td style="text-align:center;padding:8px 6px"><input type="checkbox" id="pm-trusted-cacheall"      ${tp.cacheAllImgs   ? 'checked' : ''} ${(!tp.viewLatestImg || !tp.viewAllImgs) ? 'disabled' : ''} style="cursor:pointer;width:15px;height:15px"></td>
                    <td style="text-align:center;padding:8px 6px"><input type="checkbox" id="pm-trusted-nowatermark"   ${tp.noWatermark    ? 'checked' : ''} style="cursor:pointer;width:15px;height:15px"></td>
                  </tr>
                </tbody>
              </table>
            </div>

            <!-- Watermark explanation -->
            <div style="background:var(--surface2);border:1px solid var(--border2);border-radius:6px;padding:10px 12px;margin-bottom:10px;font-size:11px;color:var(--muted2);line-height:1.6">
              <strong style="color:var(--text);font-size:11px">About watermarks:</strong>
              By default, every photo you share has two watermarks baked in invisibly before it leaves your device:
              a <strong>forensic mark</strong> — a subtle tiled pattern containing the recipient's buddy code, so you can identify who received a photo if it's ever leaked — and your own <strong>display watermark</strong> (if you've configured one in settings).
              Enabling <span style="color:var(--amber);font-weight:600">No Watermark</span> for a tier removes both completely.
              This is irreversible per-share — watermarks are applied at send time, so existing cached copies on buddy devices are already marked.
            </div>

            <!-- Trusted-tier-warning (soft, shown when trusted < buddy on any cell) -->
            <div id="perm-tier-warning" style="display:${_permTierWarning(bp, tp) ? '' : 'none'};font-size:11px;color:var(--muted2);background:var(--surface2);border:1px solid var(--border2);border-radius:4px;padding:7px 10px;margin-bottom:10px;line-height:1.5">
              ⚠ Trusted buddies currently have fewer permissions than regular buddies on some settings. This may be intentional.
            </div>

            <p style="font-size:11px;color:var(--muted2);margin:8px 0 14px 0;line-height:1.5">
              <strong>Buddy</strong> = anyone you've connected with.
              <strong>Trusted</strong> = mutual trust (both sides marked each other). Trust is a social promise, not a technical guarantee. A trusted buddy could still screenshot photos outside this app.
            </p>

            <!-- Buddy requests: block list is the only consent gate now. -->
            <div style="margin-bottom:14px;padding:10px 12px;background:rgba(99,102,241,.06);border:1px solid rgba(99,102,241,.18);border-radius:6px">
              <div style="font-size:12px;font-weight:600;color:var(--text);margin-bottom:4px">Buddy requests</div>
              <p style="font-size:11px;color:var(--muted2);margin:0;line-height:1.5">Public restorers can send you a buddy request. If you don't want to hear from someone, <strong>block them</strong> — that's the only switch. There's no global "no requests" mode anymore.</p>
            </div>

            <!-- Require mutual photo sharing -->
            <label style="display:flex;align-items:flex-start;gap:8px;cursor:pointer;margin-bottom:4px">
              <input type="checkbox" id="perm-require-mutual-photo" ${requireMutualPhoto ? 'checked' : ''} style="margin-top:2px;flex-shrink:0">
              <span style="font-size:12px;color:var(--text)">Require mutual photo sharing</span>
            </label>
            <details class="help-tip"><summary>? Why</summary><div class="help-tip-body">Only share or cache photos with buddies who are also sharing theirs. If they haven't enabled "View latest" for you, your photos stay hidden from them, and vice versa. Cache follows the same rule independently.</div></details>

            <!-- Do not cache incoming photos -->
            <label style="display:flex;align-items:flex-start;gap:8px;cursor:pointer;margin-bottom:4px">
              <input type="checkbox" id="perm-no-cache-incoming" ${noCacheIncoming ? 'checked' : ''} style="margin-top:2px;flex-shrink:0">
              <span style="font-size:12px;color:var(--text)">Do not cache any photos from buddies</span>
            </label>
            <details class="help-tip"><summary>? Why</summary><div class="help-tip-body">Photos will only be visible while both you and the buddy are online. Nothing is saved to your device. Does not affect the mutual sharing rule above — buddies can still view your photos if you share them.</div></details>

            <!-- Show buddy timer status (inbound) -->
            <label style="display:flex;align-items:flex-start;gap:8px;cursor:pointer;margin-bottom:4px">
              <input type="checkbox" id="perm-show-buddy-timer-status" ${showBuddyTimerStatus ? 'checked' : ''} style="margin-top:2px;flex-shrink:0">
              <span style="font-size:12px;color:var(--text)">Show buddies' tugging/retaining status on my dashboard</span>
            </label>
            <details class="help-tip"><summary>? Why</summary><div class="help-tip-body">Live status is ephemeral, never stored. Clears when either of you disconnects.</div></details>

            <!-- Share buddy list -->
            <label style="display:flex;align-items:flex-start;gap:8px;cursor:pointer;margin-bottom:4px">
              <input type="checkbox" id="perm-share-buddy-list" ${shareBuddyList ? 'checked' : ''} style="margin-top:2px;flex-shrink:0">
              <span style="font-size:12px;color:var(--text)">Share my buddy list with connected buddies</span>
            </label>
            <details class="help-tip"><summary>? Why</summary><div class="help-tip-body">Your buddies can see the emoji and name of your other buddies, and send them buddy requests. Only avatar and name are shared — never photos or journal data. Includes any buddies you add in the future.</div></details>

            <!-- Hide from others' buddy lists -->
            <label style="display:flex;align-items:flex-start;gap:8px;cursor:pointer;margin-bottom:4px">
              <input type="checkbox" id="perm-hide-from-buddy-lists" ${hideMeFromBuddyLists ? 'checked' : ''} style="margin-top:2px;flex-shrink:0">
              <span style="font-size:12px;color:var(--text)">Hide me from other buddies' shared lists</span>
            </label>
            <details class="help-tip"><summary>? Why</summary><div class="help-tip-body">When checked, your buddies will not include you when they share their buddy list with others.</div></details>

            <!-- Trusted-only features info -->
            <div style="background:var(--surface2);border:1px solid var(--border2);border-radius:6px;padding:10px 12px;margin-bottom:10px;font-size:11px;color:var(--muted2);line-height:1.6">
              <strong style="color:var(--text);font-size:11px">Trusted-only features:</strong>
              When trust is mutual (both sides trust each other), these additional features unlock:
              <ul style="margin:6px 0 0;padding-left:18px">
                <li><strong>Comparisons</strong> — save and share side-by-side photo comparisons via chat. Both sides can view the comparison using their own cached copies of the mutually available photos, with all transformations (zoom, rotation, pan) preserved.</li>
                <li><strong>Saved comparisons sync</strong> — your saved comparisons are automatically shared with mutually trusted buddies and synced across your own devices.</li>
              </ul>
            </div>

            <!-- Cached photo TTL for buddy tier -->
            <div style="font-family:var(--mono);font-size:11px;font-weight:700;letter-spacing:.06em;text-transform:uppercase;color:var(--muted2);margin-bottom:6px">Cached photos (buddy tier)</div>
            <div style="display:flex;align-items:center;gap:8px;margin-bottom:4px">
              <span style="font-size:12px;color:var(--muted2)">Delete cached photos after</span>
              <select id="vanish-ttl-sel" class="input" style="width:auto;font-size:12px">
                ${ttlOptions.map(d => `<option value="${d}" ${d === vanishTTL ? 'selected' : ''}>${_fmtTTL(d)}</option>`).join('')}
              </select>
              <span style="font-size:12px;color:var(--muted2)">of no sync</span>
            </div>
            <details class="help-tip" style="margin-left:0;margin-bottom:14px"><summary>? Why</summary><div class="help-tip-body">Cached photos on buddy devices are deleted after this period of no sync. Journal text and stats are controlled by the Fade setting in Settings.</div></details>

            <!-- Cached photo TTL for trusted tier -->
            <div style="font-family:var(--mono);font-size:11px;font-weight:700;letter-spacing:.06em;text-transform:uppercase;color:var(--muted2);margin-bottom:6px">Cached photos (trusted tier)</div>
            <div style="display:flex;align-items:center;gap:8px;margin-bottom:4px">
              <span style="font-size:12px;color:var(--muted2)">Delete cached photos after</span>
              <select id="trusted-ttl-sel" class="input" style="width:auto;font-size:12px">
                ${trustedTTLOptions.map(d => `<option value="${d}" ${d === trustedTTL ? 'selected' : ''}>${_fmtTTL(d)}</option>`).join('')}
              </select>
              <span style="font-size:12px;color:var(--muted2)">of no sync</span>
            </div>
            <details class="help-tip" style="margin-left:0;margin-bottom:16px"><summary>? Why</summary><div class="help-tip-body">Set to "never" to keep your photos on their device until you disconnect.</div></details>

            <button class="btn btn-primary btn-sm" style="margin-top:2px" data-action="saveBuddyPermissionsUI">Save permissions</button>

            <!-- Go solo -->
            <div style="border-top:1px solid var(--border2);padding-top:14px;margin-top:16px">
              <div style="font-family:var(--mono);font-size:11px;font-weight:700;letter-spacing:.06em;text-transform:uppercase;color:var(--muted2);margin-bottom:6px">Go solo</div>
              <p style="font-size:12px;color:var(--text);margin:0 0 6px 0;line-height:1.5">Tell all buddies to delete your data and clear your buddy list.</p>
              <details class="help-tip" style="margin-left:0;margin-bottom:10px"><summary>? Details</summary><div class="help-tip-body">Notifies all buddies to delete your data, removes your data from this device, and clears your entire buddy list. Your journal stays untouched. Buddies who are offline will receive the deletion request when they next come online.</div></details>
              <button class="btn btn-danger btn-sm" data-action="leaveQuietly">Leave quietly &amp; remove all buddies</button>
            </div>
          </div>`}
        </div>

        <!-- Blocked restorers -->
        ${(() => {
          const blocked = typeof getBlockedContacts === 'function' ? getBlockedContacts() : [];
          if (!blocked.length) return '';
          const blockedOpen = lsGet('frj_panel_blocked', false);
          return `<div class="panel${blockedOpen ? '' : ' collapsed'}" id="blocked-panel" style="margin-bottom:1rem">
            <div class="ph" style="cursor:pointer" data-action="toggleContactPanel" data-a0="blocked-panel" data-a1="frj_panel_blocked">
              <h2>Blocked (${blocked.length})</h2>
              <span style="margin-left:auto;font-size:11px;color:var(--muted2)">${blockedOpen ? '▲' : '▼'}</span>
            </div>
            <div class="pb">
              <p style="font-size:12px;color:var(--muted2);margin:0 0 10px;line-height:1.5">Blocked restorers cannot sync with you. Unblock to allow them to be added again.</p>
              ${blocked.map(b => `<div style="display:flex;align-items:center;gap:10px;padding:8px 0;border-bottom:1px solid var(--border2)">
                <span style="font-size:20px">${esc(b.avatar || '🌿')}</span>
                <div style="flex:1;min-width:0">
                  <div style="font-size:13px;font-weight:600">${esc(b.nickname || 'Restorer')}</div>
                  <div style="font-family:var(--mono);font-size:10px;color:var(--muted2)">${b.blockedAt ? 'blocked ' + timeAgo(new Date(b.blockedAt).toISOString()) : ''}</div>
                </div>
                <button class="btn btn-ghost btn-sm" data-action="unblockRestorer" data-a0="${esc(b.pubKeyB64)}">Unblock</button>
              </div>`).join('')}
            </div>
          </div>`;
        })()}

        <!-- Connect with a buddy (collapsed by default) -->
        <div class="panel${connectOpen ? '' : ' collapsed'}" id="connect-panel" style="margin-bottom:1rem">
          <div class="ph" style="cursor:pointer" data-action="toggleContactPanel" data-a0="connect-panel" data-a1="frj_panel_connect">
            <h2>Connect with a buddy</h2>
            <span style="margin-left:auto;font-size:11px;color:var(--muted2)">${connectOpen ? '▲' : '▼'}</span>
          </div>
          ${_encGate || `<div class="pb">
            ${!(netToggles.network && netToggles.buddies) ? `<div style="display:flex;align-items:center;gap:10px;background:var(--accent2);border:1px solid rgba(59,130,246,.25);border-radius:6px;padding:10px 12px;margin-bottom:14px">
              <p style="font-size:12px;color:var(--muted2);margin:0;flex:1;line-height:1.5">Turn on the buddy network to connect automatically with your buddies.</p>
              <button class="btn btn-primary btn-sm" data-action="turnOnBuddyNetwork" style="flex-shrink:0">Turn on</button>
            </div>` : ''}
            <p style="font-size:13px;color:var(--text);margin:0 0 6px 0;line-height:1.5">Swap codes with your buddy. One-time exchange.</p>
            <details class="help-tip" style="margin-left:0;margin-bottom:18px"><summary>? How it works</summary><div class="help-tip-body">After the swap, your devices find each other automatically whenever you're both online. Everything is encrypted specifically for that buddy before it leaves your device — the signal server brokers the connection but cannot read your data. You control what each buddy can see in <strong>Buddy Permissions</strong> below, and can disconnect at any time.</div></details>
            <div style="display:flex;gap:10px;margin-bottom:6px;align-items:baseline">
              <div style="font-family:var(--mono);font-size:11px;font-weight:700;color:var(--accent);min-width:16px">1</div>
              <div style="font-size:13px;font-weight:600;color:var(--text)">Send them your code</div>
            </div>
            <div style="padding-left:26px;margin-bottom:18px">
              <div class="share-code" id="my-identity-code" style="margin-bottom:8px">${esc(myCode)}</div>
              <div style="display:flex;gap:6px;flex-wrap:wrap">
                <button class="btn btn-primary btn-sm" data-action="shareMyCode" data-a0="${esc(myCode)}">Send code…</button>
                <button class="btn btn-ghost btn-sm" data-action="copyToClipboard" data-a0="${esc(myCode)}">Copy</button>
              </div>
              <p style="font-size:12px;color:var(--muted2);margin:8px 0 0 0;line-height:1.5">"Send code" opens your share menu (Signal, SimpleX, Messages, whatever you use). No share menu on your device? Use Copy.</p>
            </div>
            <div style="display:flex;gap:10px;margin-bottom:6px;align-items:baseline">
              <div style="font-family:var(--mono);font-size:11px;font-weight:700;color:var(--accent);min-width:16px">2</div>
              <div style="font-size:13px;font-weight:600;color:var(--text)">Paste their code</div>
            </div>
            <div style="padding-left:26px">
              <p style="font-size:12px;color:var(--muted2);margin:0 0 8px 0;line-height:1.5">Ask them to copy their code from this same page and send it to you.</p>
              <div class="fg" style="margin-bottom:8px">
                <textarea id="c-add-code" placeholder="FR1:…" style="min-height:52px;font-family:var(--mono);font-size:11px"></textarea>
              </div>
              <button class="btn btn-primary" data-action="addContact">+ Add Buddy</button>
            </div>
            <details style="margin-top:20px;border-top:1px solid var(--border2);padding-top:12px">
              <summary style="cursor:pointer;font-family:var(--mono);font-size:11px;font-weight:600;letter-spacing:.06em;text-transform:uppercase;color:var(--muted2);list-style:none">▸ Key Backup &amp; Restore</summary>
              <div style="margin-top:10px">
                <p style="font-family:var(--mono);font-size:11px;color:var(--muted2);line-height:1.8;margin:0 0 10px 0;background:var(--yellowl);border:1px solid var(--yellowb);border-radius:5px;padding:8px">⚠ Your backup code contains your encrypted private key. Anyone with the code <strong>and your password</strong> can impersonate you. Store both somewhere safe.</p>
                <div style="display:flex;gap:6px;flex-wrap:wrap;margin-bottom:8px">
                  <button class="btn btn-secondary btn-sm" data-action="showKeyBackup">Export backup code</button>
                  <button class="btn btn-ghost btn-sm" data-action="showKeyRestore">Restore from backup</button>
                </div>
                <div id="key-backup-area" style="display:none;margin-top:8px">
                  <div style="font-family:var(--mono);font-size:11px;color:var(--muted2);margin-bottom:4px">Your backup code (keep secret):</div>
                  <div style="position:relative">
                    <div id="key-backup-text" class="share-code" style="filter:blur(5px);user-select:none;cursor:pointer" title="Click to reveal" data-action="revealKeyBackup">Click to reveal</div>
                    <div id="key-backup-hint" style="position:absolute;inset:0;display:flex;align-items:center;justify-content:center;font-family:var(--mono);font-size:11px;color:var(--muted);pointer-events:none">click to reveal</div>
                  </div>
                  <div style="display:flex;gap:6px;margin-top:6px">
                    <button class="btn btn-ghost btn-sm" id="key-backup-copy-btn" style="display:none" data-action="copyKeyBackup">Copy</button>
                    <button class="btn btn-ghost btn-sm" data-action="hideKeyBackup">Hide</button>
                  </div>
                </div>
                <div id="key-restore-area" style="display:none;margin-top:8px">
                  <div class="fg" style="margin:0">
                    <label>Paste your backup code (FRK2: or legacy FRK1:)</label>
                    <textarea id="key-restore-input" placeholder="FRK2:… or FRK1:…" style="min-height:52px;font-family:var(--mono);font-size:11px"></textarea>
                  </div>
                  <div style="display:flex;gap:6px;margin-top:6px">
                    <button class="btn btn-danger btn-sm" data-action="doKeyRestore">Restore Identity</button>
                    <button class="btn btn-ghost btn-sm" data-action="hideKeyRestoreArea">Cancel</button>
                  </div>
                </div>
              </div>
            </details>
          </div>`}
        </div>

    </div>`;

  // Populate restorers section from live presence data
  if (typeof updateRestorersOnlineUI === 'function') updateRestorersOnlineUI();

  // Note: shareable ?add= links were removed by design -- buddy codes are
  // exchanged manually (copy the code, send it over a private channel).
}


// ─── Contacts list ────────────────────────────────────────────────────────────

function _buddySyncFade(c) {
  if (!c.lastSeen || !c.vanishTTL || c.trusted) return { style: '', tip: '' };
  const daysSince = (Date.now() - new Date(c.lastSeen).getTime()) / 86400000;
  const ratio = Math.min(1, daysSince / c.vanishTTL);
  const daysLeft = Math.max(0, c.vanishTTL - daysSince);
  if (ratio < 0.4) return { style: '', tip: `${_fmtTimeLeft(daysLeft)} until their data clears` };
  const opacity = Math.max(0.35, 1 - (ratio - 0.4) * 1.1);
  return { style: `opacity:${opacity.toFixed(2)}`, tip: daysLeft > 0 ? `${_fmtTimeLeft(daysLeft)} until their data clears` : 'data expiring soon' };
}

function _lastSeenBadge(c) {
  if (!c.lastSeen) return { dot: '#555', tip: 'never synced' };
  const days = (Date.now() - new Date(c.lastSeen).getTime()) / 86400000;
  const ago = days < 1/1440 ? 'just now' : days < 1/24 ? `${Math.round(days * 1440)}min ago` : days < 1 ? `${Math.round(days * 24)}h ago` : `${Math.floor(days)}d ago`;
  if (days <= 7)  return { dot: '#3b82f6', tip: `synced ${ago}` };
  if (days <= 14) return { dot: '#6096d8', tip: `synced ${ago}` };
  if (days <= 21) return { dot: '#7a8faa', tip: `synced ${ago}` };
  if (days <= 30) return { dot: '#8a8a8a', tip: `synced ${ago}` };
  return { dot: '#444', tip: `last synced ${ago}` };
}

const _BUDDY_EMPTY_HTML = `<div class="buddy-empty">
  <svg class="icon" aria-hidden="true" style="width:34px;height:34px;opacity:.5"><use href="#i-buddies"/></svg>
  <p>No buddies yet. Swap identity codes with a restoration buddy below to connect, privately and end-to-end encrypted.</p>
</div>`;

function renderContactsListHtml(contacts) {
  if (!contacts.length) {
    return _BUDDY_EMPTY_HTML;
  }
  const iAmEncrypted = typeof getSessionKey === 'function' && !!getSessionKey();
  const sorted = [...contacts].filter(c => !c.removedAt).sort((a, b) => {
    const aOn = isOnline(a.pubKeyB64) ? 0 : 1;
    const bOn = isOnline(b.pubKeyB64) ? 0 : 1;
    return aOn - bOn;
  });
  if (!sorted.length) {
    return _BUDDY_EMPTY_HTML;
  }
  return sorted.map(c => {
    const online = isOnline(c.pubKeyB64);
    // Away = online but their tab/screen is hidden -- render the dot in amber.
    // Distinct from local `_peerIdle` below (that one is P2P connection idleness).
    const isAway = online && typeof isBuddyIdle === 'function' && isBuddyIdle(c.pubKeyB64);
    const dotCls = online ? (isAway ? ' idle' : ' online') : '';
    const lastSeen = c.lastSeen ? timeAgo(c.lastSeen) : 'never connected';
    const entryCount = c.journal?.entries?.length;
    const journalInfo = entryCount != null ? ` · ${entryCount} entr${entryCount !== 1 ? 'ies' : 'y'}` : '';
    const trusted = !!c.trusted;
    const mutual = trusted && !!c.senderTrusts;
    // Prefer the name from their profile (set when they sync), fall back to stored nickname
    const displayName = c.journal?.displayName || c.nickname || 'New buddy';

    // Pending removal state
    if (c.pendingRemoval) {
      const pendingAge = c.pendingRemovalAt ? timeAgo(new Date(c.pendingRemovalAt).toISOString()) : null;
      const pendingMeta = online
        ? 'Sending erase signal now…'
        : `Erase signal queued - will be sent on next sync${pendingAge ? ` · pending ${pendingAge}` : ''}`;
      return `<div class="contact-item" data-peer-pub="${esc(c.pubKeyB64)}" style="opacity:0.5;border-left:3px solid var(--muted2);padding-left:9px">
        <div class="online-dot${dotCls}"></div>
        <div class="contact-item-info">
          <div class="contact-item-name">${esc(displayName)}</div>
          <div class="contact-item-meta" style="line-height:1.4">${pendingMeta}</div>
        </div>
        <div style="font-family:var(--mono);font-size:11px;color:var(--muted2)">pending</div>
      </div>`;
    }

    // Waiting for them to add you back (Phase 5)
    if (c.notBuddiesYet) {
      return `<div class="contact-item" data-peer-pub="${esc(c.pubKeyB64)}" style="opacity:0.6;border-left:3px solid var(--muted2);padding-left:9px">
        <div class="online-dot${dotCls}"></div>
        <div class="contact-item-info">
          <div class="contact-item-name">${esc(displayName)}</div>
          <div class="contact-item-meta" style="line-height:1.4">Waiting for them to add your identity code · retrying automatically…</div>
        </div>
        <button class="btn btn-danger btn-sm" data-action="deleteBuddy" data-a0="${esc(c.pubKeyB64)}" title="Remove" aria-label="Remove"><svg class="icon" aria-hidden="true"><use href="#i-trash"/></svg></button>
      </div>`;
    }

    // Ghost: buddy removed you and sent forget_me — your data was cleared from their device
    if (c.forgottenAt && c.vanished) {
      const ago = timeAgo(c.forgottenAt);
      return `<div class="contact-item" data-peer-pub="${esc(c.pubKeyB64)}" style="opacity:0.45;border-left:3px solid var(--muted2);padding-left:9px">
        <div class="contact-item-info" style="flex:1">
          <div class="contact-item-name">${esc(displayName)}</div>
          <div class="contact-item-meta" style="line-height:1.4">Removed you ${ago}. Their data has been erased from your device, and yours from theirs.</div>
        </div>
        <button class="btn btn-sm" data-action="dismissGhost" data-a0="${esc(c.pubKeyB64)}" title="Dismiss" style="font-size:11px;padding:2px 8px">dismiss</button>
      </div>`;
    }

    // Vanished (their data cleared from your device, but they haven't removed you)
    if (c.vanished && !c.journal) {
      return `<div class="contact-item" data-peer-pub="${esc(c.pubKeyB64)}" style="opacity:0.45">
        <div class="online-dot${dotCls}"></div>
        <div class="contact-item-info">
          <div class="contact-item-name">${esc(displayName)}</div>
          <div class="contact-item-meta">inactive · data cleared · will reconnect if they return</div>
        </div>
        <button class="btn btn-danger btn-sm" data-action="removeContact" data-a0="${esc(c.pubKeyB64)}" title="Remove" aria-label="Remove"><svg class="icon" aria-hidden="true"><use href="#i-trash"/></svg></button>
      </div>`;
    }

    const fade = _buddySyncFade(c);
    // Padlock icon: 🔒 untrusted, 🗝🔒 trusting (one-way), 🔓 mutual
    const padlockIcon = mutual ? '🔓' : trusted ? '🗝🔒' : '🔒';
    const padlockTitle = mutual
      ? 'Mutual trust - click to revoke your trust'
      : trusted
        ? 'You trust them - waiting for them to trust you back. Click to revoke.'
        : 'Tap to trust this buddy - shares your full journal history';
    const padlockStyle = mutual
      ? 'color:#7c3aed;border-color:#7c3aed'
      : trusted
        ? 'color:#3b82f6;border-color:#3b82f6'
        : '';
    const trustNameTag = mutual
      ? ` <span style="font-family:var(--mono);font-size:11px;color:#7c3aed;font-weight:500">trusted</span>`
      : trusted
        ? ` <span style="font-family:var(--mono);font-size:11px;color:#3b82f6;font-weight:500">trusting…</span>`
        : '';
    const idle = online && (typeof isPeerIdle === 'function') && isPeerIdle(c.pubKeyB64);
    const fadeTag = fade.style
      ? ` <span style="font-family:var(--mono);font-size:11px;color:var(--muted2)" title="${esc(fade.tip)}">⏳ ${fade.tip}</span>`
      : '';
    const lsb = _lastSeenBadge(c);
    const syncDot = !online && c.lastSeen
      ? ` <span style="display:inline-block;width:7px;height:7px;border-radius:50%;background:${lsb.dot};vertical-align:middle;margin-left:2px" title="${esc(lsb.tip)}"></span>`
      : '';
    const encTag = iAmEncrypted && c.lastSeen && !c.peerEncryptionEnabled
      ? ` <span style="font-family:var(--mono);font-size:11px;color:var(--muted2)" title="Buddy hasn't set up encryption - photos withheld until they do">🔒 photos paused</span>`
      : !iAmEncrypted && c.peerEncryptionEnabled
        ? ` <span style="font-family:var(--mono);font-size:11px;color:#f59e0b" title="This buddy is encrypted but your device isn't - their photos are stored unprotected">⚠️ unencrypted</span>`
        : iAmEncrypted && c.peerEncryptionEnabled
          ? ` <span style="font-family:var(--mono);font-size:11px;color:var(--green)" title="Both devices encrypted - full photo sync active">🔒</span>`
          : '';
    // Card-shaped buddy mini card — no photos, just identity + status + actions
    const latestEntry = (c.journal?.entries || []).slice().sort((a, b) => (b.date || '').localeCompare(a.date || ''))[0] || null;
    const currentCI = latestEntry?.ci;
    const avatarEmoji = c.journal?.avatar || '🌿';
    // Trust icon: outline shield untrusted, filled+check when mutual, filled when trusting
    const trustIcon = mutual ? 'i-shield-check' : 'i-shield';
    const trustCls  = mutual ? ' trusted' : trusted ? ' trusting' : '';
    const statusText = online ? (idle ? 'away' : 'online') : lastSeen;

    return `<div class="buddy-card${mutual ? ' mutual' : ''}" data-peer-pub="${esc(c.pubKeyB64)}" data-action="openBuddyProfile" data-a0="${esc(c.pubKeyB64)}">
      <div class="bc-head">
        <div class="bc-avatar">
          <span>${esc(avatarEmoji)}</span>
          <span class="bc-dot${dotCls}"></span>
        </div>
        <div class="bc-id"${fade.style ? ` style="${fade.style}"` : ''}>
          <div class="bc-name">${esc(displayName)}</div>
          <div class="bc-status">
            ${currentCI != null ? `<span class="bc-ci">CI-${currentCI}</span>` : ''}
            <span>${esc(statusText)}</span>
          </div>
          <div class="bc-fp" title="Identity fingerprint: ${esc(c.pubKeyB64)}">${esc(c.pubKeyB64.slice(0, 8))}…</div>
        </div>
      </div>
      ${trustNameTag || fadeTag || encTag ? `<div class="bc-tags">${trustNameTag}${fadeTag}${encTag}</div>` : ''}
      <div class="bc-actions" onclick="event.stopPropagation()">
        <button class="bc-btn" data-action="openChatPopup" data-a0="${esc(c.pubKeyB64)}" title="Chat" aria-label="Chat with ${esc(displayName)}"><svg class="icon" aria-hidden="true"><use href="#i-chat"/></svg></button>
        <button class="bc-btn" data-action="openBuddyGallery" data-a0="${esc(c.pubKeyB64)}" title="Gallery" aria-label="${esc(displayName)}'s gallery"><svg class="icon" aria-hidden="true"><use href="#i-gallery"/></svg></button>
        ${mutual ? `<button class="bc-btn" data-action="viewBuddyJournal" data-a0="${esc(c.pubKeyB64)}" title="Timeline" aria-label="${esc(displayName)}'s timeline"><svg class="icon" aria-hidden="true"><use href="#i-chart"/></svg></button>` : ''}
        <button class="bc-btn bc-trust${trustCls}" data-action="toggleTrustContact" data-a0="${esc(c.pubKeyB64)}" title="${esc(padlockTitle)}" aria-label="Trust setting"><svg class="icon" aria-hidden="true"><use href="#${trustIcon}"/></svg></button>
        <button class="bc-btn bc-remove" data-action="deleteBuddy" data-a0="${esc(c.pubKeyB64)}" title="Remove" aria-label="Remove ${esc(displayName)}"><svg class="icon" aria-hidden="true"><use href="#i-trash"/></svg></button>
      </div>
    </div>`;
  }).join('');
}

function toggleTrustContact(pubKeyB64) {
  const contacts = getContacts();
  const i = contacts.findIndex(c => c.pubKeyB64 === pubKeyB64);
  if (i < 0) return;
  const c = contacts[i];
  const displayName = c.journal?.displayName || c.nickname || 'this buddy';
  const alreadyTrusted = !!c.trusted;

  if (alreadyTrusted) {
    confirm2('Revoke trust?',
      `Remove trust for ${displayName}? They will no longer receive your full journal history.`,
      () => {
        contacts[i].trusted   = false;
        contacts[i].updatedAt = Date.now();
        saveContacts(contacts);
        renderContactsList();
        if (typeof p2pSendSettingsPush === 'function') p2pSendSettingsPush(pubKeyB64);
        if (typeof p2pPushBuddySnapshot === 'function') p2pPushBuddySnapshot();
        if (typeof syncAfterSave === 'function') syncAfterSave();
        toast('Trust removed');
      }
    );
  } else {
    confirm2('Trust this buddy?',
      `Trust ${displayName}? This shares your full journal history with them. Trust becomes mutual once they trust you back.`,
      () => {
        contacts[i].trusted   = true;
        contacts[i].updatedAt = Date.now();
        saveContacts(contacts);
        renderContactsList();
        // Send trust_ack so they know immediately, then settings_push for permissions
        if (typeof p2pSendTrustAck === 'function') p2pSendTrustAck(pubKeyB64, true);
        if (typeof p2pSendSettingsPush === 'function') p2pSendSettingsPush(pubKeyB64);
        if (typeof p2pPushBuddySnapshot === 'function') p2pPushBuddySnapshot();
        if (typeof syncAfterSave === 'function') syncAfterSave();
        toast('Trusting: full history unlocks once they trust you back');
      }
    );
  }
}

// Delete buddy = quiet vanish + remove card
function deleteBuddy(pubKeyB64) {
  const c = getContacts().find(x => x.pubKeyB64 === pubKeyB64);
  const cName = c?.journal?.displayName || c?.nickname || 'this buddy';
  const online = typeof isOnline === 'function' && isOnline(pubKeyB64);
  const everConnected = !!(c?.lastSeen || c?.journal);

  async function _doRemove() {
    if (typeof clearContactCachedData === 'function') await clearContactCachedData(pubKeyB64);
    // Block this restorer — store their info for the blocked list UI
    if (typeof addBlockedHash === 'function') {
      const hash = typeof _computePubKeyHash === 'function' ? await _computePubKeyHash(pubKeyB64) : pubKeyB64.slice(0, 16);
      addBlockedHash(hash);
      const blocked = typeof getBlockedContacts === 'function' ? getBlockedContacts() : [];
      if (!blocked.some(b => b.pubKeyB64 === pubKeyB64)) {
        blocked.push({
          pubKeyB64,
          nickname: c?.journal?.displayName || c?.nickname || 'Restorer',
          avatar: c?.journal?.avatar || '',
          blockedAt: Date.now(),
        });
        if (typeof saveBlockedContacts === 'function') saveBlockedContacts(blocked);
      }
    }
    // Send the forget signal FIRST, while the peer connection is still open, so
    // the direct WebRTC channel is used when available (instant + reliable). Tearing
    // the connection down first forced every removal through the relay, which silently
    // failed when the relay had spun down even though a live channel existed.
    let sent = false;
    if (everConnected && typeof p2pSendForgetMe === 'function') sent = await p2pSendForgetMe(pubKeyB64);

    // Now clean up P2P state (no longer touches contacts list)
    if (typeof p2pRemoveContact === 'function') p2pRemoveContact(pubKeyB64);

    const all = getContacts();
    const tombstone = { pubKeyB64, removedAt: Date.now() };
    if (!sent && everConnected) {
      tombstone.pendingRemoval = true;
      tombstone.pendingRemovalAt = Date.now();
      tombstone.pendingRemovalNonce = crypto.randomUUID?.() || ('' + Date.now() + Math.random());
    }
    saveContacts(all.map(x => x.pubKeyB64 === pubKeyB64 ? tombstone : x));
    if (typeof p2pSigRegister === 'function') p2pSigRegister();
    if (typeof syncAfterSave === 'function') syncAfterSave();
    if (_viewingContactKey === pubKeyB64) _viewingContactKey = null;
    renderContactsPage();
    // Immediately re-render any view that surfaces this buddy so the removal is
    // visible right away (dashboard buddy strip, activity feed, journal viewer
    // KOTs/comments). Without this, traces of the buddy lingered until the next
    // periodic render tick even though the underlying stores were already wiped.
    if (typeof renderDash === 'function') renderDash();
    if (typeof renderActivityPage === 'function' && document.getElementById('view-activity')?.classList.contains('active')) {
      try { renderActivityPage(); } catch(e) {}
    }
    if (typeof renderJournal === 'function' && document.getElementById('view-journal')?.classList.contains('active')) {
      try { renderJournal(); } catch(e) {}
    }
  }

  if (!everConnected) {
    // Silent auto-remove — no confirmation needed
    _doRemove();
    toast('Buddy removed.');
    return;
  }

  confirm2('Remove buddy?',
    `Remove ${cName}? Their journal, photos, and chat history will be deleted from your device.${online ? ' A signal will be sent immediately to erase your data from their device.' : ' A signal will be sent to erase your data from their device on next sync.'} Note: deletion from their device is best-effort. If the signal is never delivered, your data may remain on their device.`,
    () => {
      _doRemove();
      toast(online ? 'Buddy removed. They have been notified.' : 'Buddy removed. They will be notified on next sync.');
    }
  );
}

// Save buddy permissions and broadcast settings push if permissions were tightened
async function saveBuddyPermissionsUI() {
  const _readTier = (prefix) => ({
    timerStatus:    document.getElementById(`pm-${prefix}-timer`)?.checked          ?? false,
    viewLatestImg:  document.getElementById(`pm-${prefix}-viewlatest`)?.checked     ?? false,
    cacheLatestImg: document.getElementById(`pm-${prefix}-cachelatest`)?.checked    ?? false,
    viewAllImgs:    document.getElementById(`pm-${prefix}-viewall`)?.checked        ?? false,
    cacheAllImgs:   document.getElementById(`pm-${prefix}-cacheall`)?.checked       ?? false,
    noWatermark:    document.getElementById(`pm-${prefix}-nowatermark`)?.checked    ?? false,
  });
  const newBuddy   = _readTier('buddy');
  const newTrusted = _readTier('trusted');
  const showBuddyTimerStatus = document.getElementById('perm-show-buddy-timer-status')?.checked ?? false;
  const requireMutualPhoto   = document.getElementById('perm-require-mutual-photo')?.checked ?? true;
  const noCacheIncoming      = document.getElementById('perm-no-cache-incoming')?.checked ?? false;
  const shareBuddyList       = document.getElementById('perm-share-buddy-list')?.checked ?? true;
  const hideMeFromBuddyLists = document.getElementById('perm-hide-from-buddy-lists')?.checked ?? false;
  const vanishTTL  = parseFloat(document.getElementById('vanish-ttl-sel')?.value)  || 90;
  const trustedTTL = parseFloat(document.getElementById('trusted-ttl-sel')?.value) || 0;

  const prevPerms  = (typeof getBuddyPermissions === 'function') ? getBuddyPermissions() : { v:4, buddy:{}, trusted:{} };
  const prevVanish = (typeof getVanishSettings   === 'function') ? getVanishSettings()   : {};

  const _doSave = async () => {
    const tightened =
      (prevPerms.buddy?.viewLatestImg   && !newBuddy.viewLatestImg)   ||
      (prevPerms.buddy?.cacheLatestImg  && !newBuddy.cacheLatestImg)  ||
      (prevPerms.buddy?.viewAllImgs     && !newBuddy.viewAllImgs)     ||
      (prevPerms.buddy?.cacheAllImgs    && !newBuddy.cacheAllImgs)    ||
      (prevPerms.trusted?.viewLatestImg  && !newTrusted.viewLatestImg)  ||
      (prevPerms.trusted?.cacheLatestImg && !newTrusted.cacheLatestImg) ||
      (prevPerms.trusted?.viewAllImgs    && !newTrusted.viewAllImgs)    ||
      (prevPerms.trusted?.cacheAllImgs   && !newTrusted.cacheAllImgs);

    if (typeof saveBuddyPermissions === 'function') {
      saveBuddyPermissions({ v: 4, buddy: Object.assign({}, newBuddy, { shareBuddyList }), trusted: Object.assign({}, newTrusted, { shareBuddyList }), showBuddyTimerStatus, requireMutualPhoto, noCacheIncoming, hideMeFromBuddyLists });
    }
    if (typeof saveVanishSettings === 'function') saveVanishSettings({ ...prevVanish, ttl: vanishTTL, trustedTTL });
    if (typeof stampSettingsTs    === 'function') stampSettingsTs();
    if (typeof p2pBroadcastSettingsPush === 'function') await p2pBroadcastSettingsPush();
    else if (typeof p2pBroadcastJournal === 'function') await p2pBroadcastJournal();
    // Push snapshot so relay-only buddies also get updated permissions
    if (typeof p2pPushBuddySnapshot === 'function') p2pPushBuddySnapshot();
    // Re-broadcast buddy list if sharing or hide settings changed
    if (shareBuddyList !== (prevPerms.buddy?.shareBuddyList !== false) || hideMeFromBuddyLists !== !!prevPerms.hideMeFromBuddyLists) {
      if (typeof p2pBroadcastBuddyList === 'function') await p2pBroadcastBuddyList();
    }
    if (typeof syncAfterSave === 'function') syncAfterSave();
    toast(tightened ? 'Permissions saved. Buddies notified.' : 'Permissions saved');
  };

  // Require confirmation when "cache all" is newly enabled
  const buddyCacheAllNew   = newBuddy.cacheAllImgs   && !prevPerms.buddy?.cacheAllImgs;
  const trustedCacheAllNew = newTrusted.cacheAllImgs && !prevPerms.trusted?.cacheAllImgs;
  if (buddyCacheAllNew || trustedCacheAllNew) {
    const who = (buddyCacheAllNew && trustedCacheAllNew) ? 'Buddy and Trusted tiers'
              : buddyCacheAllNew ? 'Buddy tier' : 'Trusted tier';
    confirm2(
      `Allow ${who} to cache all photos?`,
      `All your past progress photos (except private entries) will be kept on their device in encrypted form. You can revoke this at any time. Their device will delete the copies when you next sync.`,
      'Enable',
      async () => { await _doSave(); }
    );
    return;
  }

  await _doSave();
}


function saveVanishTTL(val) {
  const ttl = parseInt(val);
  if (isNaN(ttl)) return;
  const s = (typeof getVanishSettings === 'function') ? getVanishSettings() : {};
  s.ttl = ttl;
  if (typeof saveVanishSettings === 'function') saveVanishSettings(s);
  if (typeof stampSettingsTs  === 'function') stampSettingsTs();
  if (typeof p2pBroadcastSettingsPush === 'function') p2pBroadcastSettingsPush();
  else if (typeof p2pBroadcastJournal === 'function') p2pBroadcastJournal();
  if (typeof syncAfterSave === 'function') syncAfterSave();
  toast('Vanish setting saved');
}

// ─── Add / remove contacts ────────────────────────────────────────────────────

async function addContact() {
  const code = (document.getElementById('c-add-code')?.value || '').trim();
  if (!code) { toast('Paste their buddy code first', true); return; }

  const parsed = parseIdentityCode(code);
  if (!parsed) { toast('Invalid code. Should start with FR1:…', true); return; }

  const identity = await getIdentity();
  if (identity && parsed.pubKeyB64 === identity.pubKeyB64) {
    toast("That's your own code!", true); return;
  }

  const existing = getContacts().find(c => c.pubKeyB64 === parsed.pubKeyB64);
  if (existing) { toast('Already added', true); return; }

  const _doAdd = () => {
    const contact = {
      pubKeyB64: parsed.pubKeyB64,
      nickname:  'Buddy-' + parsed.pubKeyB64.slice(0, 6), // placeholder until first sync
      lastSeen:  null,
      journal:   null,
      updatedAt: Date.now(),
      senderViewScope:  'none',  // safe default until peer announces their scopes
      senderCacheScope: 'none',
      senderAllowsCache: false
    };
    const contacts = getContacts();
    contacts.push(contact);
    saveContacts(contacts);
    if (typeof p2pSigRegister === 'function') p2pSigRegister();
    if (typeof syncAfterSave === 'function') syncAfterSave();
    // Retry connection after registrations complete (handles race with mutual buddy check)
    if (typeof _ensureBuddyConnection === 'function') _ensureBuddyConnection(parsed.pubKeyB64);
    // Push our latest snapshot + photo to the relay queue for the new buddy.
    // The on-connect push only fires for buddies who were already in the contacts
    // list at registration time, so a newly-added buddy would otherwise see nothing
    // until our next journal entry save.
    if (typeof p2pPushBuddySnapshot === 'function') p2pPushBuddySnapshot();

    if (document.getElementById('c-add-code')) document.getElementById('c-add-code').value = '';
    renderContactsPage();
    toast('Buddy added! Their name will appear once you connect.');
  };

  if (!localStorage.getItem('frj_buddy_warned')) {
    confirm2('Add this buddy?',
      'Adding a buddy means your devices will find each other automatically whenever you are both online. They will see your journal stats (CI level, progress, streaks). If you enable photo sharing, they can view your photos. Only add people you know and trust.',
      () => { localStorage.setItem('frj_buddy_warned', '1'); _doAdd(); });
  } else {
    _doAdd();
  }
}

function vanishFromContact(pubKeyB64) {
  const c = getContacts().find(x => x.pubKeyB64 === pubKeyB64);
  const cName = c?.journal?.displayName || c?.nickname || 'this buddy';
  const online = typeof isOnline === 'function' && isOnline(pubKeyB64);
  confirm2('Disconnect & vanish?',
    `Your data will be cleared from ${cName}'s device${online ? ' now' : ' when they come online'}. Their data will be cleared from yours. The contact card stays. You can reconnect any time.`,
    async () => {
      await clearContactCachedData(pubKeyB64);
      if (typeof p2pSendForgetMe === 'function') await p2pSendForgetMe(pubKeyB64);
      if (_viewingContactKey === pubKeyB64) _viewingContactKey = null;
      renderContactsList();
      toast('Vanished. Signal sent.');
    }
  );
}

async function unblockRestorer(pubKeyB64) {
  confirm2('Unblock restorer?', 'This restorer will be able to connect with you again via buddy request.', async () => {
    // Compute hash so we can both update local list and tell the signal server to drop it
    const hash = typeof _computePubKeyHash === 'function' ? await _computePubKeyHash(pubKeyB64) : pubKeyB64.slice(0, 16);
    if (typeof removeBlockedHash === 'function') removeBlockedHash(hash);
    // Remove from blocked contacts list
    if (typeof getBlockedContacts === 'function' && typeof saveBlockedContacts === 'function') {
      saveBlockedContacts(getBlockedContacts().filter(b => b.pubKeyB64 !== pubKeyB64));
    }
    // Remove the tombstone so they can re-add us
    const all = getContacts();
    saveContacts(all.filter(c => c.pubKeyB64 !== pubKeyB64));
    // Tell signal server to drop the per-WS block so directory + restorer network shows them again
    try { if (typeof _sigSend === 'function') _sigSend({ type: 'presence_unblock', hash }); } catch(e) {}
    // Refresh directory + restorer network UI immediately
    if (typeof p2pRequestDirectory === 'function') p2pRequestDirectory();
    if (typeof p2pSigRegister === 'function') p2pSigRegister();
    if (typeof updateRestorersOnlineUI === 'function') updateRestorersOnlineUI();
    // Sync the unblock to own paired devices
    if (typeof syncAfterSave === 'function') syncAfterSave();
    renderContactsPage();
    toast('Restorer unblocked — they will appear in the network shortly');
  });
}

async function leaveQuietly() {
  const contacts = getContacts();
  const count = contacts.length;
  if (!count) { toast('No buddies to remove.'); return; }
  confirm2('Go solo?',
    `Notify all ${count} buddy${count !== 1 ? 'ies' : ''} to delete your data, clear their cached data from this device, and remove everyone from your buddy list. Your journal is untouched. Note: deletion from each buddy's device is best-effort. If a signal is never delivered, your data may remain on their device.`,
    async () => {
      // Step 1: Send forget_me to all buddies (server queues for offline ones)
      for (const c of contacts) {
        if (typeof p2pSendForgetMe === 'function') await p2pSendForgetMe(c.pubKeyB64);
      }
      // Step 2: Clear data and drop connections (safe now — messages already sent)
      for (const c of contacts) {
        if (typeof clearContactCachedData === 'function') await clearContactCachedData(c.pubKeyB64);
        if (typeof p2pRemoveContact === 'function') p2pRemoveContact(c.pubKeyB64);
      }
      // Wipe the entire contacts list
      if (typeof saveContacts === 'function') saveContacts([]);
      // Re-register with signal server (empty contacts list)
      if (typeof p2pSigRegister === 'function') p2pSigRegister();
      _viewingContactKey = null;
      renderContactsPage();
      toast('All buddies removed. Flying solo.');
    }
  );
}

function dismissGhost(pubKeyB64) {
  // Remove the ghost notification card — buddy already removed us and data is cleared
  const contacts = getContacts();
  saveContacts(contacts.map(c => c.pubKeyB64 === pubKeyB64 ? { pubKeyB64, removedAt: Date.now() } : c));
  if (typeof p2pSigRegister === 'function') p2pSigRegister();
  renderContactsPage();
  if (typeof renderDash === 'function') renderDash();
}

function removeContact(pubKeyB64) {
  const c = getContacts().find(x => x.pubKeyB64 === pubKeyB64);
  const cName = c?.journal?.displayName || c?.nickname || 'this buddy';
  const online = typeof isOnline === 'function' && isOnline(pubKeyB64);
  confirm2('Remove buddy?',
    `Remove ${cName}? Their journal, photos, and chat history will be deleted from your device. A signal will be sent to erase your data from their device${online ? ' now' : ' when they come online'}. Note: deletion from their device is best-effort. If the signal is never delivered, your data may remain on their device.`,
    async () => {
      // Step 1: immediately clear their cached data from your device
      if (typeof clearContactCachedData === 'function') await clearContactCachedData(pubKeyB64);
      // Step 2: send forget signal — server queues it if buddy is offline
      if (typeof p2pSendForgetMe === 'function') await p2pSendForgetMe(pubKeyB64);
      // Step 3: tombstone contact + disconnect (tombstone propagates removal to paired devices)
      if (typeof p2pRemoveContact === 'function') p2pRemoveContact(pubKeyB64);
      const all = getContacts();
      saveContacts(all.map(c => c.pubKeyB64 === pubKeyB64 ? { pubKeyB64, removedAt: Date.now() } : c));
      if (typeof p2pSigRegister === 'function') p2pSigRegister();
      if (_viewingContactKey === pubKeyB64) _viewingContactKey = null;
      renderContactsPage();
      toast(online ? 'Buddy removed. Signal sent.' : 'Buddy removed. Signal queued - will deliver when they come online.');
    }
  );
}

// ─── Contact journal viewer ───────────────────────────────────────────────────

function viewContactJournal(pubKeyB64) {
  _viewingContactKey = pubKeyB64;
  const el = document.getElementById('contact-journal-panel');
  if (el) el.innerHTML = renderContactJournalPanel(pubKeyB64);
}

function renderContactJournalPanel(pubKeyB64) {
  const c = getContacts().find(x => x.pubKeyB64 === pubKeyB64);
  if (!c) return '';
  const online = isOnline(pubKeyB64);
  const syncTime = c.lastSeen ? timeAgo(c.lastSeen) : null;
  const journal = getPeerJournal(pubKeyB64);

  const displayName = journal?.displayName || c.nickname || 'Buddy';

  if (!journal) {
    return `<div class="panel">
      <div class="contact-journal-banner">
        ${esc(c.journal?.displayName || c.nickname || 'Buddy')}'s journal
        <span style="opacity:.5;margin-left:auto;font-size:11px">${online ? '● online' : '○ offline'}</span>
      </div>
      <div class="empty"><div class="icon">◳</div>
        <p>${online ? 'Connected, waiting for data…' : 'Not connected yet. Both of you need to be online at the same time for the first sync.'}</p>
      </div>
    </div>`;
  }

  const entries = (journal.entries || []).sort((a, b) => b.date.localeCompare(a.date));
  const mutualTrust = !!(c.trusted && c.senderTrusts);
  const pendingTrust = !!(c.trusted && !c.senderTrusts);
  const copyNote = mutualTrust
    ? `<span style="color:#7c3aed;margin-left:6px" title="Mutual trust: full photo history shared">trusted</span>`
    : pendingTrust
      ? `<span style="color:#3b82f6;margin-left:6px" title="You trust them, waiting for them to trust you back">trusting…</span>`
      : `<span style="color:var(--green);margin-left:6px" title="Latest entry and stats saved">connected</span>`;

  const profile = journal.profile || {};
  const avatar  = journal.avatar || null;
  const latestEntry = entries[0];
  const currentCI = latestEntry?.ci;
  const profileOpen = localStorage.getItem('frj_cpanel_profile_' + pubKeyB64.slice(0, 8)) === '1';

  // Build profile rows
  const profileRows = [];
  if (currentCI != null) profileRows.push(`<tr><td style="color:var(--muted2);padding:2px 10px 2px 0">Current CI</td><td><span class="ci-badge ${ciBadgeClass(currentCI)}">CI-${currentCI}</span></td></tr>`);
  if (profile.goalCI != null) profileRows.push(`<tr><td style="color:var(--muted2);padding:2px 10px 2px 0">Goal</td><td>CI-${esc(String(profile.goalCI))}</td></tr>`);
  if (profile.startingCI != null) profileRows.push(`<tr><td style="color:var(--muted2);padding:2px 10px 2px 0">Starting CI</td><td>CI-${esc(String(profile.startingCI))}</td></tr>`);
  if (profile.startDate) profileRows.push(`<tr><td style="color:var(--muted2);padding:2px 10px 2px 0">Restoring since</td><td>${esc(profile.startDate)}</td></tr>`);

  // Build buddy list chips
  const buddyListHtml = (c.buddyList && c.buddyList.length) ? c.buddyList.filter(b => {
    if (typeof _ownPubKeyHash !== 'undefined' && _ownPubKeyHash && b.hash === _ownPubKeyHash) return false;
    return true;
  }).map(b => {
    const myContacts = getContacts();
    const alreadyBuddy = myContacts.some(mc => !mc.removedAt && !mc.forgottenAt && mc._pubKeyHash === b.hash);
    if (alreadyBuddy) {
      return `<span style="font-size:13px;padding:3px 8px;border-radius:12px;background:var(--surface2);color:var(--muted2)">${esc(b.emoji || '\u{1f33f}')} ${esc(b.name || 'Restorer')}</span>`;
    }
    return `<button class="btn btn-ghost btn-sm" style="font-size:13px;padding:3px 8px;border-radius:12px"
      data-action="openRestorerProfile" data-a0="${esc(b.hash)}" data-a1="${esc(b.emoji || '\u{1f33f}')}" data-a2="${esc(b.name || 'Restorer')}" data-a3="0">
      ${esc(b.emoji || '\u{1f33f}')} ${esc(b.name || 'Restorer')}
    </button>`;
  }).join('') : '';
  const hasProfileContent = profileRows.length || buddyListHtml;

  return `<div class="panel">
    <div class="contact-journal-banner">
      ${esc(displayName)}'s journal
      <span style="opacity:.5;margin-left:auto;font-size:11px">${online ? '● online' : '○'} ${syncTime ? 'synced ' + syncTime : ''}</span>
      ${copyNote}
      <button class="btn btn-secondary btn-sm" data-action="openBuddyGallery" data-a0="${esc(pubKeyB64)}"
        title="View gallery" style="margin-left:8px;font-size:13px;padding:2px 7px">🖼</button>
    </div>
    ${hasProfileContent ? `
    <div style="border-bottom:1px solid var(--border2)">
      <div style="cursor:pointer;padding:8px 12px;display:flex;align-items:center;gap:8px" data-action="toggleBuddyProfile" data-a0="${esc(pubKeyB64)}">
        <span style="font-size:22px">${esc(avatar || '\u{1f33f}')}</span>
        <span style="font-size:13px;font-weight:600;color:var(--text);flex:1">${esc(displayName)}</span>
        <span style="font-size:11px;color:var(--muted2)">${profileOpen ? '▲' : '▼'}</span>
      </div>
      ${profileOpen ? `<div style="padding:0 12px 10px">
        ${profileRows.length ? `<table style="font-size:12px;border-collapse:collapse;margin-bottom:${buddyListHtml ? '10px' : '0'}">${profileRows.join('')}</table>` : ''}
        ${buddyListHtml ? `
          <div style="font-family:var(--mono);font-size:11px;font-weight:700;letter-spacing:.06em;text-transform:uppercase;color:var(--muted2);margin-bottom:6px">${esc(displayName)}'s buddies</div>
          <div style="display:flex;flex-wrap:wrap;gap:6px">${buddyListHtml}</div>
        ` : ''}
      </div>` : ''}
    </div>` : ''}
    <div style="padding:8px 12px;font-family:var(--mono);font-size:11px;color:var(--muted2);border-bottom:1px solid var(--border2)">
      ${esc(displayName)} · ${entries.length} entr${entries.length !== 1 ? 'ies' : 'y'}
    </div>
    ${(() => {
      if (!entries.length) return '<div class="empty" style="padding:1rem"><p>No entries</p></div>';
      const _canViewPhotos = typeof p2pCanViewBuddyPhotos === 'function' ? p2pCanViewBuddyPhotos(pubKeyB64) : true;
      return entries.map(e => {
        const thumbKey = `c_${pubKeyB64.slice(0, 16)}_${e.id}_front`;
        // Only show photo if mutual check passes AND the entry's hasImgFront is true (sender permits this specific entry)
        const thumbSrc = (_canViewPhotos && e.hasImgFront) ? imgURL(thumbKey) : null;
        if (_canViewPhotos && online && e.hasImgFront && !thumbSrc) {
          p2pRequestImage(pubKeyB64, e.id, 'front');
        }
        return `<div class="li" data-action="viewContactEntry" data-a0="${esc(pubKeyB64)}" data-a1="${esc(e.id)}">
          ${thumbSrc
            ? `<img style="width:32px;height:32px;object-fit:cover;border-radius:4px;flex-shrink:0" src="${thumbSrc}" alt="">`
            : `<div style="width:32px;height:32px;border-radius:4px;background:var(--surface2);flex-shrink:0"></div>`}
          <div style="flex:1;min-width:0">
            <div class="li-name" style="font-size:12px">${esc(e.date)}</div>
            <div class="li-meta">
              <span class="ci-badge ${ciBadgeClass(e.ci)}">CI-${e.ci}</span>
              ${e.pctTugged != null ? ' · ' + Math.round(e.pctTugged * 7 / 100) + 'd' + (e.avgHrsTugged != null ? ' · ' + e.avgHrsTugged + 'h/d' : '') : ''}
            </div>
          </div>
        </div>`;
      }).join('');
    })()}
  </div>
  `;
}

function viewContactEntry(pubKeyB64, entryId) {
  tab('journal');
  setTimeout(() => selectBuddyEntry(pubKeyB64, entryId), 50);
}

function showContactEntryDetail(contact, e, online) {
  const _canView = typeof p2pCanViewBuddyPhotos === 'function' ? p2pCanViewBuddyPhotos(contact.pubKeyB64) : true;
  if (_canView && online && e.hasImgFront) {
    p2pRequestImage(contact.pubKeyB64, e.id, 'front');
  }

  function contactImg(field) {
    if (!_canView) return null;
    const has = e[`hasImg${field.charAt(0).toUpperCase() + field.slice(1)}`];
    if (!has) return null; // sender doesn't permit this entry's photo
    return imgURL(`c_${contact.pubKeyB64.slice(0, 16)}_${e.id}_${field}`);
  }
  function photoSlot(field, label) {
    const src = contactImg(field);
    const has = e[`hasImg${field.charAt(0).toUpperCase() + field.slice(1)}`];
    const denied = typeof p2pIsImgDenied === 'function' && p2pIsImgDenied(contact.pubKeyB64, e.id, field);
    if (src) return `<div><div style="font-family:var(--mono);font-size:11px;color:var(--muted2);margin-bottom:3px">${label}</div>${photoCanvas(`c_${contact.pubKeyB64.slice(0,16)}_${e.id}_${field}`, { watermarkName: contact.journal?.displayName || contact.nickname || 'Buddy', style: 'width:100%;border-radius:5px' })}</div>`;
    if (has && denied) return `<div><div style="font-family:var(--mono);font-size:11px;color:var(--muted2)">${label}</div><div style="background:var(--surface2);border-radius:5px;padding:18px;text-align:center;font-family:var(--mono);font-size:11px;color:var(--muted2)">Photo unavailable</div></div>`;
    if (has && online) return `<div><div style="font-family:var(--mono);font-size:11px;color:var(--muted2)">${label}</div><div style="background:var(--surface2);border-radius:5px;padding:18px;text-align:center;font-family:var(--mono);font-size:11px;color:var(--muted2)">Loading…</div></div>`;
    if (has) return `<div><div style="font-family:var(--mono);font-size:11px;color:var(--muted2)">${label}</div><div style="background:var(--surface2);border-radius:5px;padding:18px;text-align:center;font-family:var(--mono);font-size:11px;color:var(--muted2)">Buddy offline</div></div>`;
    return '';
  }

  let overlay = document.getElementById('contactEntryModal');
  if (!overlay) {
    overlay = document.createElement('div');
    overlay.id = 'contactEntryModal';
    overlay.className = 'modal-overlay';
    overlay.innerHTML = `<div class="modal-box" style="max-width:640px;max-height:90vh;overflow-y:auto">
      <div class="modal-hdr"><span id="cemTitle"></span><button data-action="closeModal" data-a0="contactEntryModal">✕</button></div>
      <div class="modal-body" id="contactEntryBody"></div>
    </div>`;
    document.body.appendChild(overlay);
  }
  document.getElementById('cemTitle').textContent = `${contact.journal?.displayName || contact.nickname || 'Buddy'} · ${e.date}`;
  document.getElementById('contactEntryBody').innerHTML = `
    <div style="margin-bottom:14px">
      ${photoSlot('front','Progress Photo')}
    </div>
    <div style="display:grid;grid-template-columns:repeat(2,1fr);gap:10px;font-size:13px;margin-bottom:12px">
      <div><div style="font-family:var(--mono);font-size:11px;color:var(--muted2)">CI</div><span class="ci-badge ${ciBadgeClass(e.ci)}">CI-${e.ci}</span></div>
      <div><div style="font-family:var(--mono);font-size:11px;color:var(--muted2)">DAYS TUGGED</div>${e.pctTugged != null ? Math.round(e.pctTugged * 7 / 100) + ' / 7' : '-'}</div>
      <div><div style="font-family:var(--mono);font-size:11px;color:var(--muted2)">DAYS RETAINED</div>${e.pctRetained != null ? Math.round(e.pctRetained * 7 / 100) + ' / 7' : '-'}</div>
      <div><div style="font-family:var(--mono);font-size:11px;color:var(--muted2)">HRS/DAY TUGGED</div>${e.avgHrsTugged != null ? e.avgHrsTugged + 'h/day' : '-'}</div>
      <div><div style="font-family:var(--mono);font-size:11px;color:var(--muted2)">HRS/DAY RETAINED</div>${e.avgHrsRetained != null ? e.avgHrsRetained + 'h/day' : '-'}</div>
      ${e.bmi ? `<div><div style="font-family:var(--mono);font-size:11px;color:var(--muted2)">BMI</div>${e.bmi}</div>` : ''}
    </div>
    ${e.tuggingDevices?.length ? `<div style="margin-bottom:10px"><div style="font-family:var(--mono);font-size:11px;color:var(--muted2);margin-bottom:4px">TUGGING DEVICES</div><div style="display:flex;flex-wrap:wrap;gap:4px">${e.tuggingDevices.map(d=>`<span class="rv-chip">${esc(d)}</span>`).join('')}</div></div>` : ''}
    ${e.retainingDevices?.length ? `<div style="margin-bottom:10px"><div style="font-family:var(--mono);font-size:11px;color:var(--muted2);margin-bottom:4px">RETAINING DEVICES</div><div style="display:flex;flex-wrap:wrap;gap:4px">${e.retainingDevices.map(d=>`<span class="rv-chip">${esc(d)}</span>`).join('')}</div></div>` : ''}
    ${e.supplements ? `<div style="margin-bottom:10px"><div style="font-family:var(--mono);font-size:11px;color:var(--muted2);margin-bottom:4px">SUPPLEMENTS / TOPICALS</div><div style="font-size:13px;white-space:pre-wrap">${esc(e.supplements)}</div></div>` : ''}
    ${e.notes ? `<div style="margin-bottom:12px"><div style="font-family:var(--mono);font-size:11px;color:var(--muted2);margin-bottom:4px">NOTES</div><div style="background:var(--yellowl);border:1px solid var(--yellowb);border-radius:5px;padding:9px;font-size:12px;white-space:pre-wrap">${esc(e.notes)}</div></div>` : ''}
    <div style="display:flex;align-items:center;gap:10px;margin-bottom:12px">
      ${(() => {
        const sent = (typeof getSentKot === 'function') ? getSentKot(contact.pubKeyB64, e.id) : null;
        if (sent) return '<button class="btn btn-sm kot-btn kot-active" disabled title="Already KOT\'d">✊ KOT\'d</button>';
        return '<button class="btn btn-sm kot-btn" data-action="cemSendKot" data-a0="' + esc(contact.pubKeyB64) + '" data-a1="' + esc(e.id) + '">✊ KOT</button>';
      })()}
    </div>
    ${(() => {
      const existing = (typeof getSentNote === 'function') ? getSentNote(contact.pubKeyB64, e.id) : null;
      const btnLabel = existing ? 'Edit comment' : 'Send comment';
      return '<div style="margin-bottom:12px">' +
        '<div style="font-family:var(--mono);font-size:11px;color:var(--muted2);margin-bottom:4px">' + (existing ? 'Your comment' : 'Leave a comment') + '</div>' +
        '<textarea id="cem-note-ta" rows="3" maxlength="1000" style="width:100%;resize:vertical;font-size:13px;font-family:var(--body);padding:8px;border-radius:5px;border:1px solid var(--border2);background:var(--surface2);color:var(--text);box-sizing:border-box" placeholder="Leave a comment…">' + (existing ? esc(existing.content) : '') + '</textarea>' +
        '<div style="display:flex;justify-content:flex-end;margin-top:6px">' +
        '<button class="btn btn-primary btn-sm" data-action="cemSendComment" data-a0="' + esc(contact.pubKeyB64) + '" data-a1="' + esc(e.id) + '">' + btnLabel + '</button>' +
        '</div></div>';
    })()}
    `;
  overlay.dataset.peerKey = contact.pubKeyB64;
  overlay.dataset.entryId = e.id;
  overlay.classList.add('open');
}

function refreshContactImages(pubKeyB64, entryId) {
  refreshPeerJournalPanel(pubKeyB64);
  // Re-render the journal tab detail view if this buddy entry is currently selected
  if (typeof _selBuddyKey !== 'undefined' && _selBuddyKey === pubKeyB64 &&
      typeof _selEntryId !== 'undefined' && _selEntryId === entryId &&
      typeof selectBuddyEntry === 'function') {
    selectBuddyEntry(pubKeyB64, entryId);
  }
  // Update contact entry modal if open
  const modal = document.getElementById('contactEntryModal');
  if (modal?.classList.contains('open')) {
    const c = getContacts().find(x => x.pubKeyB64 === pubKeyB64);
    const journal = getPeerJournal(pubKeyB64);
    if (!journal) return;
    const e = (journal.entries || []).find(x => x.id === entryId);
    if (e) showContactEntryDetail(c, e, isOnline(pubKeyB64));
  }
}

// ─── Helpers ──────────────────────────────────────────────────────────────────

async function saveDisplayName() {
  const name = (document.getElementById('c-display-name')?.value || '').trim();
  if (!name) return;
  await setDisplayName(name);
  toast('Display name saved ✓');
}

// ─── Key backup / restore UI ──────────────────────────────────────────────────

let _backupCode = null; // held in memory only while the panel is open

async function showKeyBackup() {
  const area = document.getElementById('key-backup-area');
  const restoreArea = document.getElementById('key-restore-area');
  if (!area) return;
  if (restoreArea) restoreArea.style.display = 'none';
  try {
    // Always produce an encrypted FRK2 code — require a password regardless of whether
    // at-rest encryption is enabled. If encryption is enabled, verify against the existing
    // password so there's no second password to remember.
    const sessionKey = typeof getSessionKey === 'function' ? getSessionKey() : null;
    const encEnabled = !!(sessionKey);
    const pw = prompt(encEnabled
      ? 'Enter your encryption password to create an encrypted backup code:'
      : 'Choose a password to protect your backup code (you will need it to restore):');
    if (!pw) return;
    if (encEnabled) {
      const ok = typeof verifyEncryptionPassword === 'function' ? await verifyEncryptionPassword(pw) : false;
      if (!ok) { toast('Incorrect password', true); return; }
    }
    _backupCode = await exportIdentityBackup(pw);
  } catch(e) {
    toast('Could not export key: ' + e.message, true);
    return;
  }
  const textEl = document.getElementById('key-backup-text');
  const hintEl = document.getElementById('key-backup-hint');
  const copyBtn = document.getElementById('key-backup-copy-btn');
  if (textEl) {
    textEl.textContent = _backupCode;
    textEl.style.filter = 'blur(5px)';
    textEl.style.userSelect = 'none';
    textEl.style.cursor = 'pointer';
    textEl.onclick = revealKeyBackup;
  }
  if (hintEl) hintEl.style.display = '';
  if (copyBtn) copyBtn.style.display = 'none';
  area.style.display = '';
}

function revealKeyBackup() {
  const textEl = document.getElementById('key-backup-text');
  const hintEl = document.getElementById('key-backup-hint');
  const copyBtn = document.getElementById('key-backup-copy-btn');
  if (!textEl) return;
  textEl.style.filter = 'none';
  textEl.style.userSelect = 'text';
  textEl.style.cursor = 'text';
  textEl.onclick = null;
  if (hintEl) hintEl.style.display = 'none';
  if (copyBtn) copyBtn.style.display = '';
}

function copyKeyBackup() {
  if (_backupCode) copyToClipboard(_backupCode);
}

function hideKeyBackup() {
  _backupCode = null;
  const area = document.getElementById('key-backup-area');
  if (area) area.style.display = 'none';
}

function showKeyRestore() {
  hideKeyBackup();
  const area = document.getElementById('key-restore-area');
  if (area) area.style.display = '';
}

async function doKeyRestore() {
  const code = (document.getElementById('key-restore-input')?.value || '').trim();
  if (!code) { toast('Paste your backup code (FRK2: or FRK1:) first', true); return; }

  // FRK2 codes require a password — prompt before confirming
  let restorePw = null;
  if (code.startsWith('FRK2:')) {
    restorePw = prompt('This backup is encrypted. Enter the password used when it was created:');
    if (!restorePw) return;
  }

  confirm2(
    'Restore identity?',
    'This will replace your current keypair with the backed-up one. Existing contacts will reconnect automatically. Continue?',
    async () => {
      try {
        await importIdentityBackup(code, restorePw);
        if (document.getElementById('key-restore-input')) document.getElementById('key-restore-input').value = '';
        document.getElementById('key-restore-area').style.display = 'none';
        if (typeof p2pReinit === 'function') await p2pReinit();
        renderContactsPage();
        toast('Identity restored ✓');
      } catch(e) {
        toast('Restore failed: ' + e.message, true);
      }
    }
  );
}

// Called from p2p.js when a buddy goes offline — immediately go dark on live-only content
function _onBuddyOffline(pubKeyB64) {
  // Refresh contact journal panel (imgURL now returns null for evicted RAM-only images)
  refreshPeerJournalPanel(pubKeyB64);

  // Refresh open contact entry modal if it's showing this buddy's entry
  const modal = document.getElementById('contactEntryModal');
  if (modal?.classList.contains('open') && modal.dataset.peerKey === pubKeyB64) {
    const c = getContacts().find(x => x.pubKeyB64 === pubKeyB64);
    const journal = getPeerJournal(pubKeyB64);
    const e = journal ? (journal.entries || []).find(x => x.id === modal.dataset.entryId) : null;
    if (c && e) {
      showContactEntryDetail(c, e, false);
    } else {
      modal.classList.remove('open');
    }
  }

  // Refresh journal tab if it's viewing this buddy's entry
  if (typeof _selBuddyKey !== 'undefined' && _selBuddyKey === pubKeyB64 &&
      typeof _selEntryId !== 'undefined' && _selEntryId &&
      typeof selectBuddyEntry === 'function') {
    selectBuddyEntry(pubKeyB64, _selEntryId);
  }
}

function _toggleBuddyProfile(pubKeyB64) {
  const key = 'frj_cpanel_profile_' + pubKeyB64.slice(0, 8);
  const isOpen = localStorage.getItem(key) === '1';
  localStorage.setItem(key, isOpen ? '0' : '1');
  if (typeof viewContactJournal === 'function') viewContactJournal(pubKeyB64);
}

function _toggleContactPanel(id, storageKey) {
  const p = document.getElementById(id);
  if (!p) return;
  p.classList.toggle('collapsed');
  // Update chevron
  const chevron = p.querySelector('.ph span:last-child');
  if (chevron) chevron.textContent = p.classList.contains('collapsed') ? '▼' : '▲';
  localStorage.setItem(storageKey, p.classList.contains('collapsed') ? '0' : '1');
}

function toggleAllowOffline(val) {
  setAllowOfflineCopy(val);
  renderContactsPage(); // re-render to update the description text
}

// ── Chat Popup ─────────────────────────────────────────────────────────────────
let _chatPopupKey = null;

function openChatPopup(pubKeyB64) {
  _chatPopupKey = pubKeyB64;
  const contacts = getContacts();
  const c = contacts.find(x => x.pubKeyB64 === pubKeyB64);
  const modal = document.getElementById('chatModal');
  if (!modal) return;
  const avatar = document.getElementById('chatModalAvatar');
  const name   = document.getElementById('chatModalName');
  const status = document.getElementById('chatModalStatus');
  if (avatar) avatar.textContent = c?.journal?.avatar || '🌿';
  if (name)   name.textContent   = c?.journal?.displayName || c?.nickname || 'Buddy';
  const online = typeof isOnline === 'function' && isOnline(pubKeyB64);
  if (status) status.textContent = online ? '● online' : '○ offline';
  // Trusted buddy chat theme
  const mutualTrust = !!(c?.trusted && c?.senderTrusts);
  const box = modal.querySelector('.modal-box');
  if (box) {
    if (mutualTrust) {
      box.style.border = '2px solid #7c3aed';
      box.style.boxShadow = '0 0 0 1px #7c3aed55, 0 8px 30px rgba(124,58,237,.18)';
    } else {
      box.style.border = '';
      box.style.boxShadow = '';
    }
  }
  const hdr = modal.querySelector('.modal-hdr');
  if (hdr) {
    if (mutualTrust) {
      hdr.style.borderBottom = '2px solid #7c3aed44';
      hdr.style.background = 'linear-gradient(135deg, #7c3aed0f, transparent)';
    } else {
      hdr.style.borderBottom = '';
      hdr.style.background = '';
    }
  }
  if (typeof clearChatUnread === 'function') clearChatUnread(pubKeyB64);
  if (typeof updatePeerStatus === 'function') updatePeerStatus();
  renderChatPopup(pubKeyB64);
  // Inject photo picker into the modal — rebuild footer with 📷 button
  const _cpFooter = modal.querySelector('.chat-modal-footer');
  if (_cpFooter) {
    // Remove old picker if present
    const oldPicker = modal.querySelector('#chatPhotoPicker');
    if (oldPicker) oldPicker.remove();
    // Insert picker div ABOVE the messages area so it appears at the top of the modal
    const pickerDiv = document.createElement('div');
    pickerDiv.id = 'chatPhotoPicker';
    pickerDiv.style.cssText = 'display:none;border-bottom:1px solid var(--border2);flex:1;overflow-y:auto;padding:10px;min-height:0';
    const msgsEl = modal.querySelector('#chatModalMsgs');
    if (msgsEl) msgsEl.parentNode.insertBefore(pickerDiv, msgsEl);
    else _cpFooter.parentNode.insertBefore(pickerDiv, _cpFooter);
    // Rebuild footer with photo button
    const _cpInput = _cpFooter.querySelector('#chatModalInput');
    const _cpInputVal = _cpInput?.value || '';
    _cpFooter.innerHTML = `
      <button id="chatPhotoBtn" title="Share a photo" style="background:var(--surface2);border:1px solid var(--border2);border-radius:8px;cursor:pointer;padding:6px 10px;line-height:1;flex-shrink:0"><svg class="icon" aria-hidden="true"><use href="#i-camera"/></svg></button>
      <input id="chatModalInput" class="chat-modal-input" type="text" placeholder="Message…" maxlength="1000" autocomplete="off" value="${esc(_cpInputVal)}">
      <button class="chat-modal-send" data-action="sendChatModalMsg" title="Send" ${mutualTrust ? 'style="background:#7c3aed;border-color:#7c3aed"' : ''}><svg class="icon" aria-hidden="true"><use href="#i-send"/></svg></button>`;
    // Direct click handler on the photo button (bypasses delegation entirely)
    modal.querySelector('#chatPhotoBtn').addEventListener('click', (ev) => {
      ev.stopPropagation();
      toggleChatPhotoPicker();
    });
  }
  modal.classList.add('open');
  requestAnimationFrame(() => {
    const msgs = document.getElementById('chatModalMsgs');
    if (msgs) msgs.scrollTop = msgs.scrollHeight;
    const inp = document.getElementById('chatModalInput');
    if (inp) inp.focus();
    // Second pass after images/layout settle
    requestAnimationFrame(() => { if (msgs) msgs.scrollTop = msgs.scrollHeight; });
  });
}

function closeChatPopup() {
  const modal = document.getElementById('chatModal');
  if (modal) modal.classList.remove('open');
  if (_chatPopupKey && typeof clearChatUnread === 'function') clearChatUnread(_chatPopupKey);
  _chatPopupKey = null;
  if (typeof updatePeerStatus === 'function') updatePeerStatus();
}

const _CHAT_TTL_BANNER = `<div style="text-align:center;font-size:11px;color:var(--muted2);padding:8px 0 4px;font-family:var(--mono)">Messages auto-delete after 30 days</div>`;

// Shared message-list HTML builder -- used by both the chat popup and the
// full-page messenger (js/messenger.js). Returns banner + bubble markup.
function _chatMsgListHTML(pubKeyB64) {
  const hist = getChatHistory(pubKeyB64).slice().sort((a, b) => (a.ts || '') < (b.ts || '') ? -1 : 1);
  if (!hist.length) {
    return _CHAT_TTL_BANNER + `<div style="text-align:center;font-size:11px;color:var(--muted2);padding:8px 0 16px">No messages yet</div>`;
  }
  return _CHAT_TTL_BANNER + hist.map(m => {
    const out = m.from === 'me';
    const time = m.ts ? new Date(m.ts).toLocaleTimeString([], {hour:'2-digit',minute:'2-digit'}) : '';
    const statusIcon = out ? (m.failed ? ' ✕' : m.delivered ? ' ✓' : ' ○') : '';
    // Photo message — render as image bubble
    const photoKey = m.imgKey || (m.text && m.text.startsWith('[photo:') ? m.text.slice(7, -1) : null);
    if (photoKey) {
      const src = typeof imgURL === 'function' ? imgURL(photoKey) : null;
      return `<div class="chat-modal-msg ${out ? 'out' : 'in'}" style="padding:4px;max-width:60%">
        ${src
          ? `<img src="${src}" style="width:100%;max-height:200px;object-fit:contain;border-radius:8px;display:block;cursor:pointer" data-action="openLb" data-a0="${src}">`
          : `<div style="width:120px;height:90px;border-radius:8px;background:var(--surface2);display:flex;align-items:center;justify-content:center;font-size:10px;color:var(--muted2)">Loading...</div>`}
        <div class="chat-modal-msg-meta">${time}${statusIcon}</div>
      </div>`;
    }
    // Comparison message — render side-by-side inline
    if (m.text && m.text.startsWith('[comparison:') && m.text.endsWith(']')) {
      try {
        const ref = JSON.parse(m.text.slice(12, -1));
        // Set loaded comp so resolver can access creatorSl16 for gallery photos
        if (typeof _cmpLoadedComp !== 'undefined') _cmpLoadedComp = ref;
        const _rk = typeof _resolveComparePhotoKey === 'function' ? _resolveComparePhotoKey : () => null;
        const _fb = typeof _srcFallbackKey === 'function' ? _srcFallbackKey : () => null;
        const _eA = ref.entryA && typeof getEntry === 'function' ? getEntry(ref.entryA) : null;
        const _eB = ref.entryB && typeof getEntry === 'function' ? getEntry(ref.entryB) : null;
        const _kA = _rk(ref.srcA, _eA) || _fb(ref.srcA) || ref.keyA;
        const _kB = _rk(ref.srcB, _eB) || _fb(ref.srcB) || ref.keyB;
        const _sA = _kA ? imgURL(_kA) : null;
        const _sB = _kB ? imgURL(_kB) : null;
        const tA = ref.transformA || { scale: 1, rot: 0, ox: 0, oy: 0 };
        const tB = ref.transformB || { scale: 1, rot: 0, ox: 0, oy: 0 };
        const _thumb = (src, key, t) => {
          const tf = typeof _scaleCmpTransformCSS === 'function' ? _scaleCmpTransformCSS(t) : `translate(${t.ox||0}px,${t.oy||0}px) scale(${t.scale||1}) rotate(${t.rot||0}deg)`;
          if (src) return `<div style="flex:1;aspect-ratio:3/4;overflow:hidden;border-radius:4px;background:var(--surface3);position:relative">
            <img src="${src}" style="position:absolute;inset:0;width:100%;height:100%;object-fit:contain;transform:${tf};transform-origin:center center;pointer-events:none">
          </div>`;
          if (key) return `<div style="flex:1;aspect-ratio:3/4;overflow:hidden;border-radius:4px;background:var(--surface3)">
            <canvas class="photo-canvas" data-img-key="${esc(key)}" style="width:100%;height:100%;display:block"></canvas>
          </div>`;
          return `<div style="flex:1;aspect-ratio:3/4;border-radius:4px;background:var(--surface3);display:flex;align-items:center;justify-content:center;font-size:9px;color:var(--muted2)">...</div>`;
        };
        return `<div class="chat-modal-msg ${out ? 'out' : 'in'}" style="padding:6px;max-width:85%">
          <div style="font-size:11px;font-weight:600;margin-bottom:4px">📊 ${esc(ref.name || 'Comparison')}</div>
          <div style="display:flex;gap:3px;margin-bottom:4px">${_thumb(_sA, _kA, tA)}${_thumb(_sB, _kB, tB)}</div>
          <div style="font-family:var(--mono);font-size:9px;color:${out ? 'rgba(255,255,255,.6)' : 'var(--muted2)'}">${esc(ref.descA || '?')} vs ${esc(ref.descB || '?')}</div>
          <div class="chat-modal-msg-meta">${time}${statusIcon}</div>
        </div>`;
      } catch(e) {}
    }
    return `<div class="chat-modal-msg ${out ? 'out' : 'in'}">
      ${esc(m.text)}
      <div class="chat-modal-msg-meta">${time}${statusIcon}</div>
    </div>`;
  }).join('');
}

function renderChatPopup(pubKeyB64) {
  const msgs = document.getElementById('chatModalMsgs');
  if (!msgs) return;
  msgs.innerHTML = _chatMsgListHTML(pubKeyB64);
  msgs.scrollTop = msgs.scrollHeight;
}

function refreshChatPopup(pubKeyB64) {
  if (_chatPopupKey !== pubKeyB64) return;
  if (typeof clearChatUnread === 'function') clearChatUnread(pubKeyB64);
  renderChatPopup(pubKeyB64);
  const contacts = getContacts();
  const c = contacts.find(x => x.pubKeyB64 === pubKeyB64);
  const status = document.getElementById('chatModalStatus');
  if (status) {
    const online = typeof isOnline === 'function' && isOnline(pubKeyB64);
    status.textContent = online ? '● online' : '○ offline';
  }
}

async function sendChatPopupMsg() {
  if (!_chatPopupKey) return;
  const input = document.getElementById('chatModalInput');
  if (!input) return;
  const text = input.value.trim().slice(0, 500);
  if (!text) return;
  input.value = '';
  const id = uuid();
  const ts  = new Date().toISOString();
  appendChat(_chatPopupKey, { id, from: 'me', text, ts, delivered: false, failed: false });
  if (typeof syncAfterSave === 'function') syncAfterSave(); // sync sent message to paired devices
  renderChatPopup(_chatPopupKey);
  try {
    const result = await p2pSendChatMessage(_chatPopupKey, text, id);
    if (result === 'direct') {
      _updateChatMsgStatus(_chatPopupKey, id, { delivered: true });
    }
  } catch(e) {
    _updateChatMsgStatus(_chatPopupKey, id, { failed: true });
  }
}
const sendChatModalMsg = sendChatPopupMsg;

// ─── Chat photo picker ───────────────────────────────────────────────────────

// Inject photo picker UI into the chat modal if not already present
function _ensureChatPhotoPickerUI(modal) {
  if (!modal) return;
  // Add picker panel before the footer if missing
  if (!modal.querySelector('#chatPhotoPicker')) {
    const footer = modal.querySelector('.chat-modal-footer');
    if (footer) {
      const pickerDiv = document.createElement('div');
      pickerDiv.id = 'chatPhotoPicker';
      pickerDiv.style.cssText = 'display:none;border-top:1px solid var(--border2);max-height:200px;overflow-y:auto;padding:8px';
      footer.parentNode.insertBefore(pickerDiv, footer);
    }
  }
  // Add 📷 button to the footer if missing
  const footer = modal.querySelector('.chat-modal-footer');
  if (footer && !footer.querySelector('[data-action="toggleChatPhotoPicker"]')) {
    const btn = document.createElement('button');
    btn.setAttribute('data-action', 'toggleChatPhotoPicker');
    btn.title = 'Share a photo';
    btn.style.cssText = 'background:var(--surface2);border:1px solid var(--border2);border-radius:8px;cursor:pointer;padding:6px 10px;line-height:1;flex-shrink:0';
    btn.innerHTML = '<svg class="icon" aria-hidden="true"><use href="#i-camera"/></svg>';
    footer.insertBefore(btn, footer.firstChild);
  }
}

let _chatPhotoPickerTab = 'mine';
let _chatPickerRetryTimer = null;

function toggleChatPhotoPicker() {
  let picker = document.getElementById('chatPhotoPicker');
  if (!picker) return;
  const msgsEl = document.getElementById('chatModalMsgs');
  if (picker.style.display !== 'none' && picker.innerHTML) {
    picker.style.display = 'none';
    if (msgsEl) msgsEl.style.display = '';
    return;
  }
  _chatPhotoPickerTab = 'mine';
  _chatPhotoPickerEl = 'chatPhotoPicker';
  _chatPhotoPickerPub = _chatPopupKey;
  try { _renderChatPhotoPicker(); } catch(e) { console.error('[chat] render error:', e); }
  picker.style.display = '';
  if (msgsEl) msgsEl.style.display = 'none';
  // Re-render picker after images load from IDB
  clearTimeout(_chatPickerRetryTimer);
  _chatPickerRetryTimer = setTimeout(() => { try { _renderChatPhotoPicker(); } catch(e) {} }, 500);
}

let _chatPhotoPickerEl = 'chatPhotoPicker'; // which picker element to render into
let _chatPhotoPickerPub = null; // pubKeyB64 for peer chat panel picker

function togglePeerChatPhotoPicker(pubKeyB64) {
  // Try the static element first; fall back to creating one dynamically
  let picker = document.getElementById('peerChatPhotoPicker');
  if (!picker) {
    // Element doesn't exist — inject it before the chat footer
    const footer = document.querySelector('[data-action="togglePeerChatPhotoPicker"]')?.closest('div');
    if (!footer) return;
    picker = document.createElement('div');
    picker.id = 'peerChatPhotoPicker';
    picker.style.cssText = 'border-top:1px solid var(--border2);max-height:45vh;overflow-y:auto;padding:10px;background:var(--surface)';
    footer.parentNode.insertBefore(picker, footer);
  } else if (picker.style.display !== 'none' && picker.innerHTML) {
    picker.style.display = 'none'; return;
  }
  _chatPhotoPickerTab = 'mine';
  _chatPhotoPickerEl = picker.id;
  _chatPhotoPickerPub = pubKeyB64;
  try {
    _renderChatPhotoPicker();
  } catch(e) { console.error('[chat] photo picker render error:', e); }
  picker.style.display = '';
  clearTimeout(_chatPickerRetryTimer);
  _chatPickerRetryTimer = setTimeout(() => { try { _renderChatPhotoPicker(); } catch(e) {} }, 500);
}

function _renderChatPhotoPicker() {
  const picker = document.getElementById(_chatPhotoPickerEl || 'chatPhotoPicker');
  const pubKeyB64 = _chatPhotoPickerPub || _chatPopupKey;
  if (!picker || !pubKeyB64) return;
  const sl16 = pubKeyB64.slice(0, 16);

  const photos = [];

  if (_chatPhotoPickerTab === 'mine') {
    // Only show journal photos the buddy has permission to see
    const contact = typeof getContacts === 'function' ? getContacts().find(c => c.pubKeyB64 === pubKeyB64) : null;
    const perm = (contact && typeof resolvePermissionsForContact === 'function') ? resolvePermissionsForContact(contact) : {};
    const entries = typeof getEntries === 'function' ? getEntries().sort((a, b) => b.date.localeCompare(a.date)) : [];
    if (perm.viewAll || perm.cacheAll) {
      // Buddy can see all photos — show all non-private
      for (const e of entries) {
        if (e.imgFront && !e.imgPrivate) photos.push({ key: e.imgFront, label: e.date });
      }
    } else if (perm.viewLatest || perm.cacheLatest) {
      // Buddy can only see latest photo
      const latest = entries.find(e => e.imgFront && !e.imgPrivate);
      if (latest) photos.push({ key: latest.imgFront, label: latest.date });
    }
    // Gallery photos — already permission-gated by galleryContactCanAccess
    const galleries = typeof getGalleries === 'function' ? getGalleries() : [];
    for (const g of galleries) {
      if (contact && typeof galleryContactCanAccess === 'function' && !galleryContactCanAccess(g, contact)) continue;
      for (const item of g.items) {
        photos.push({ key: item.imgKey, label: `${g.title} — ${item.title || ''}` });
      }
    }
  } else {
    const journal = typeof getPeerJournal === 'function' ? getPeerJournal(pubKeyB64) : null;
    for (const e of (journal?.entries || []).sort((a, b) => b.date.localeCompare(a.date))) {
      const key = `c_${sl16}_${e.id}_front`;
      if (e.hasImgFront) photos.push({ key, label: e.date });
    }
    const recv = typeof getGalleryReceived === 'function' ? getGalleryReceived() : {};
    const byGal = recv[sl16] || {};
    for (const [, galData] of Object.entries(byGal)) {
      for (const [, itemData] of Object.entries(galData.items || {})) {
        if (itemData.idbKey) photos.push({ key: itemData.idbKey, label: `${galData.title || 'Gallery'} — ${itemData.title || ''}` });
      }
    }
  }

  // Build DOM directly with click handlers (event delegation doesn't work inside modal)
  picker.innerHTML = '';

  // Tab buttons — include "Comparisons" tab only for mutually trusted buddies
  const contact = typeof getContacts === 'function' ? getContacts().find(c => c.pubKeyB64 === pubKeyB64) : null;
  const _isMutualTrust = !!(contact?.trusted && contact?.senderTrusts);
  const tabs = ['mine', 'buddy'];
  if (_isMutualTrust) tabs.push('comparisons');
  tabs.push('quickpic');
  const tabRow = document.createElement('div');
  tabRow.style.cssText = 'display:flex;gap:6px;margin-bottom:8px';
  for (const t of tabs) {
    const btn = document.createElement('button');
    btn.className = `btn btn-sm ${_chatPhotoPickerTab === t ? 'btn-primary' : 'btn-ghost'}`;
    btn.textContent = t === 'mine' ? 'Yours' : t === 'buddy' ? "Buddy's" : t === 'comparisons' ? 'Comparisons' : '📸 Quick Pic';
    btn.addEventListener('click', (ev) => { ev.stopPropagation(); _chatPhotoPickerTab = t; _renderChatPhotoPicker(); });
    tabRow.appendChild(btn);
  }
  picker.appendChild(tabRow);

  // Comparisons tab — show only comparisons involving this buddy
  if (_chatPhotoPickerTab === 'comparisons') {
    const _allSaved = typeof getSavedComparisons === 'function' ? getSavedComparisons() : [];
    const saved = typeof _comparisonInvolvesBuddy === 'function'
      ? _allSaved.filter(c => _comparisonInvolvesBuddy(c, pubKeyB64))
      : _allSaved;
    if (!saved.length) {
      const empty = document.createElement('div');
      empty.style.cssText = 'text-align:center;font-size:11px;color:var(--muted2);padding:16px 0';
      empty.textContent = 'No saved comparisons. Save one from the Compare tab first.';
      picker.appendChild(empty);
    } else {
      const list = document.createElement('div');
      list.style.cssText = 'display:flex;flex-direction:column;gap:6px';
      for (const cmp of saved) {
        const row = document.createElement('div');
        row.style.cssText = 'display:flex;align-items:center;gap:8px;padding:8px;border:1px solid var(--border2);border-radius:6px;cursor:pointer;background:var(--surface2)';
        // Resolve preview keys
        const keyA = cmp.srcA ? cmp.srcA : (cmp.entryA && typeof getEntry === 'function' ? getEntry(cmp.entryA)?.imgFront : null);
        const keyB = cmp.srcB ? cmp.srcB : (cmp.entryB && typeof getEntry === 'function' ? getEntry(cmp.entryB)?.imgFront : null);
        const srcA = keyA && typeof imgURL === 'function' ? imgURL(keyA) : null;
        const srcB = keyB && typeof imgURL === 'function' ? imgURL(keyB) : null;
        row.innerHTML = `
          <div style="display:flex;gap:3px;flex-shrink:0">
            <div style="width:36px;height:36px;border-radius:4px;overflow:hidden;background:var(--surface3);border:1px solid var(--border)">
              ${srcA ? `<img src="${srcA}" style="width:100%;height:100%;object-fit:cover">` : ''}
            </div>
            <div style="width:36px;height:36px;border-radius:4px;overflow:hidden;background:var(--surface3);border:1px solid var(--border)">
              ${srcB ? `<img src="${srcB}" style="width:100%;height:100%;object-fit:cover">` : ''}
            </div>
          </div>
          <div style="flex:1;min-width:0">
            <div style="font-size:12px;font-weight:600;color:var(--text);white-space:nowrap;overflow:hidden;text-overflow:ellipsis">${esc(cmp.name || 'Comparison')}</div>
            <div style="font-family:var(--mono);font-size:10px;color:var(--muted2)">${cmp.createdAt ? new Date(cmp.createdAt).toLocaleDateString() : ''}</div>
          </div>
          <span style="font-size:16px;flex-shrink:0">➤</span>`;
        row.addEventListener('click', (ev) => {
          ev.stopPropagation();
          _chatSendComparison(pubKeyB64, cmp);
        });
        list.appendChild(row);
      }
      picker.appendChild(list);
    }
    return;
  }

  // Quick Pic tab — file/camera picker for ephemeral photo
  if (_chatPhotoPickerTab === 'quickpic') {
    const qpDiv = document.createElement('div');
    qpDiv.style.cssText = 'display:flex;flex-direction:column;align-items:center;gap:12px;padding:16px 0';
    qpDiv.innerHTML = `
      <div style="font-size:12px;color:var(--muted2);text-align:center;line-height:1.5">Send a photo that self-destructs in 1 hour.<br>Encrypted + watermarked. Not saved to galleries.</div>
      <img id="qp-preview" style="display:none;width:100%;max-width:280px;border-radius:8px;border:2px solid #f59e0b" data-no-context="1" data-no-drag="1">
      <div style="display:flex;gap:8px">
        <button id="qp-pick-btn" class="btn btn-sm" style="font-size:13px;padding:8px 20px;background:#f59e0b;color:#fff;border-color:#f59e0b">📸 Choose Photo</button>
        <button id="qp-send-btn" class="btn btn-sm" style="display:none;font-size:13px;padding:8px 20px;background:#f59e0b;color:#fff;border-color:#f59e0b">Send Quick Pic</button>
        <button id="qp-retake-btn" class="btn btn-ghost btn-sm" style="display:none;font-size:13px;padding:8px 20px">Change</button>
      </div>`;
    picker.appendChild(qpDiv);

    let _qpBlob = null;
    const preview = qpDiv.querySelector('#qp-preview');
    const pickBtn = qpDiv.querySelector('#qp-pick-btn');
    const sendBtn = qpDiv.querySelector('#qp-send-btn');
    const retakeBtn = qpDiv.querySelector('#qp-retake-btn');

    function _qpOpenFilePicker() {
      const input = document.createElement('input');
      input.type = 'file'; input.accept = 'image/*'; input.style.display = 'none';
      document.body.appendChild(input);
      input.addEventListener('change', () => {
        document.body.removeChild(input);
        if (input.files[0]) {
          if (input.files[0].size > 10 * 1024 * 1024) { toast('Image too large (max 10 MB)', true); return; }
          _qpBlob = input.files[0];
          preview.src = URL.createObjectURL(_qpBlob);
          preview.style.display = '';
          pickBtn.style.display = 'none';
          sendBtn.style.display = '';
          retakeBtn.style.display = '';
        }
      });
      input.click();
    }

    pickBtn.addEventListener('click', (ev) => { ev.stopPropagation(); _qpOpenFilePicker(); });
    retakeBtn.addEventListener('click', (ev) => { ev.stopPropagation(); _qpBlob = null; preview.style.display = 'none'; sendBtn.style.display = 'none'; retakeBtn.style.display = 'none'; pickBtn.style.display = ''; _qpOpenFilePicker(); });

    sendBtn.addEventListener('click', async (ev) => {
      ev.stopPropagation();
      if (!_qpBlob) return;
      sendBtn.disabled = true;
      sendBtn.textContent = 'Sending…';
      await _sendQuickPic(pubKeyB64, _qpBlob);
      _qpBlob = null;
    });

    return;
  }

  if (!photos.length) {
    const empty = document.createElement('div');
    empty.style.cssText = 'text-align:center;font-size:11px;color:var(--muted2);padding:16px 0';
    empty.textContent = 'No photos available';
    picker.appendChild(empty);
  } else {
    const grid = document.createElement('div');
    grid.style.cssText = 'display:grid;grid-template-columns:repeat(auto-fill,minmax(80px,1fr));gap:6px';
    for (const p of photos) {
      const cell = document.createElement('div');
      cell.style.cssText = 'aspect-ratio:1;border-radius:4px;overflow:hidden;cursor:pointer;border:1px solid var(--border2);background:var(--surface2)';
      cell.title = p.label;
      const src = typeof imgURL === 'function' ? imgURL(p.key) : null;
      if (src) {
        const img = document.createElement('img');
        img.src = src;
        img.style.cssText = 'width:100%;height:100%;object-fit:cover;pointer-events:none';
        cell.appendChild(img);
      } else if (p.key) {
        // Use canvas for lazy loading — MutationObserver will render when blob arrives
        const cv = document.createElement('canvas');
        cv.className = 'photo-canvas';
        cv.dataset.imgKey = p.key;
        cv.style.cssText = 'width:100%;height:100%;object-fit:cover;display:block';
        cell.appendChild(cv);
      } else {
        cell.innerHTML = '<div style="width:100%;height:100%;display:flex;align-items:center;justify-content:center;font-size:9px;color:var(--muted2)">...</div>';
      }
      cell.addEventListener('click', (ev) => { ev.stopPropagation(); chatSendPhoto(p.key, p.label); });
      grid.appendChild(cell);
    }
    picker.appendChild(grid);
  }
}

function chatPhotoPickerTab(tab) {
  _chatPhotoPickerTab = tab;
  _renderChatPhotoPicker();
}

async function chatSendPhoto(imgKey, label) {
  const targetKey = _chatPhotoPickerPub || _chatPopupKey;
  if (!targetKey || !imgKey) return;
  // Close all possible pickers and restore messages
  const picker1 = document.getElementById('chatPhotoPicker');
  const picker2 = document.getElementById('peerChatPhotoPicker');
  const picker3 = document.getElementById('msgrPhotoPicker');
  if (picker1) picker1.style.display = 'none';
  if (picker2) picker2.style.display = 'none';
  if (picker3) picker3.style.display = 'none';
  const msgsEl = document.getElementById('chatModalMsgs');
  if (msgsEl) msgsEl.style.display = '';
  const msgrMsgsEl = document.getElementById('msgrMsgs');
  if (msgrMsgsEl) msgrMsgsEl.style.display = '';

  // Compress + watermark photo for sending (same rules as journal/gallery photos)
  let photoB64 = null;
  try {
    let blob = typeof idbGet === 'function' ? await idbGet(imgKey) : null;
    if (blob instanceof Blob) {
      // Compress to 400px
      blob = await new Promise((resolve) => {
        const url = URL.createObjectURL(blob);
        const img = new Image();
        img.onload = () => {
          URL.revokeObjectURL(url);
          const maxDim = 400, q = 0.7;
          const scale = Math.min(1, maxDim / Math.max(img.naturalWidth || 1, img.naturalHeight || 1));
          const w = Math.round((img.naturalWidth || 1) * scale);
          const h = Math.round((img.naturalHeight || 1) * scale);
          const c = document.createElement('canvas');
          c.width = w; c.height = h;
          c.getContext('2d').drawImage(img, 0, 0, w, h);
          c.toBlob(b => resolve(b || null), 'image/jpeg', q);
        };
        img.onerror = () => { URL.revokeObjectURL(url); resolve(null); };
        img.src = url;
      });
      // Apply watermark (same as journal/gallery — forensic + display)
      if (blob && typeof _watermarkBlob === 'function') {
        blob = await _watermarkBlob(blob, targetKey.slice(0, 20), false);
      }
      if (blob) {
        const reader = new FileReader();
        photoB64 = await new Promise(resolve => {
          reader.onload = () => resolve(reader.result);
          reader.onerror = () => resolve(null);
          reader.readAsDataURL(blob);
        });
      }
    }
  } catch(e) { console.warn('[chat] photo compress error:', e); }

  // Store locally — reference the original imgKey (no duplication, we already have the blob)
  const id = uuid();
  const ts = new Date().toISOString();
  appendChat(targetKey, { id, from: 'me', text: label || 'Photo', ts, delivered: false, failed: false, imgKey });
  if (_chatPopupKey === targetKey) renderChatPopup(targetKey);
  if (typeof refreshMessenger === 'function') refreshMessenger(targetKey);
  const peerMsgs = document.getElementById('peer-chat-messages');
  if (peerMsgs && typeof renderChatMessages === 'function') peerMsgs.innerHTML = renderChatMessages(targetKey);

  // Send with embedded photo data so receiver can display it without having the blob
  const chatPayload = photoB64 ? `[chatphoto:${id}]` : `[photo:${imgKey}]`;
  try {
    const result = await _sendChatPhotoMessage(targetKey, chatPayload, id, photoB64);
    if (result === 'direct') _updateChatMsgStatus(targetKey, id, { delivered: true });
  } catch(e) {
    _updateChatMsgStatus(targetKey, id, { failed: true });
  }
}

// ── Quick Pic — ephemeral photo, 1hr self-destruct ──────────────────────────

async function _sendQuickPic(targetKey, rawBlob) {
  if (!targetKey || !rawBlob) return;
  // Close picker
  const picker1 = document.getElementById('chatPhotoPicker');
  const picker2 = document.getElementById('peerChatPhotoPicker');
  if (picker1) picker1.style.display = 'none';
  if (picker2) picker2.style.display = 'none';
  const msgsEl = document.getElementById('chatModalMsgs');
  if (msgsEl) msgsEl.style.display = '';

  // Compress to 400px
  let blob = await new Promise((resolve) => {
    const url = URL.createObjectURL(rawBlob);
    const img = new Image();
    img.onload = () => {
      URL.revokeObjectURL(url);
      const maxDim = 400, q = 0.7;
      const scale = Math.min(1, maxDim / Math.max(img.naturalWidth || 1, img.naturalHeight || 1));
      const w = Math.round((img.naturalWidth || 1) * scale);
      const h = Math.round((img.naturalHeight || 1) * scale);
      const c = document.createElement('canvas');
      c.width = w; c.height = h;
      c.getContext('2d').drawImage(img, 0, 0, w, h);
      c.toBlob(b => resolve(b || null), 'image/jpeg', q);
    };
    img.onerror = () => { URL.revokeObjectURL(url); resolve(null); };
    img.src = url;
  });
  if (!blob) { toast('Failed to compress photo', true); return; }

  // Watermark
  if (typeof _watermarkBlob === 'function') blob = await _watermarkBlob(blob, targetKey.slice(0, 20), false);
  if (!blob) { toast('Watermark failed', true); return; }

  // Convert to base64
  const reader = new FileReader();
  const photoB64 = await new Promise(r => { reader.onload = () => r(reader.result); reader.onerror = () => r(null); reader.readAsDataURL(blob); });
  if (!photoB64) { toast('Failed to encode photo', true); return; }

  // Store ephemeral blob in IDB with temporary key
  const id = uuid();
  const qpKey = `quickpic_${id}`;
  if (typeof idbPut === 'function') await idbPut(qpKey, blob);
  if (typeof imgCache !== 'undefined') imgCache[qpKey] = URL.createObjectURL(blob);

  const ts = new Date().toISOString();
  const expiresAt = new Date(Date.now() + 3600000).toISOString(); // 1 hour
  appendChat(targetKey, { id, from: 'me', text: `[quickpic:${id}]`, ts, delivered: false, failed: false, imgKey: qpKey, quickpic: true, expiresAt });
  if (_chatPopupKey === targetKey) renderChatPopup(targetKey);
  if (typeof refreshMessenger === 'function') refreshMessenger(targetKey);
  const peerMsgs = document.getElementById('peer-chat-messages');
  if (peerMsgs && typeof renderChatMessages === 'function') peerMsgs.innerHTML = renderChatMessages(targetKey);

  try {
    const result = await _sendChatPhotoMessage(targetKey, `[quickpic:${id}]`, id, photoB64, true);
    if (result === 'direct') _updateChatMsgStatus(targetKey, id, { delivered: true });
  } catch(e) {
    _updateChatMsgStatus(targetKey, id, { failed: true });
  }
}

// Drain queued chat photos when peer connects via DC
function drainChatPhotoQueue(pubKeyB64) {
  const queue = typeof lsGet === 'function' ? lsGet('frj_chat_photo_queue', {}) : {};
  const msgs = queue[pubKeyB64];
  if (!msgs || !msgs.length) return;
  const dc = typeof _getDC === 'function' ? _getDC(pubKeyB64) : null;
  if (!dc || dc.readyState !== 'open') return;
  for (const msg of msgs) {
    if (typeof _dcSend === 'function') _dcSend(dc, msg);
  }
  delete queue[pubKeyB64];
  if (typeof lsSet === 'function') lsSet('frj_chat_photo_queue', queue);
  console.log(`[chat] drained ${msgs.length} queued photo(s) to ${pubKeyB64.slice(0, 8)}…`);
}

// Purge expired quick pics from chat history and IDB
function _purgeExpiredQuickPics() {
  const contacts = typeof getContacts === 'function' ? getContacts() : [];
  const now = Date.now();
  for (const c of contacts) {
    if (!c.pubKeyB64) continue;
    const hist = getChatHistory(c.pubKeyB64);
    let dirty = false;
    const expiredIds = [];
    for (let i = hist.length - 1; i >= 0; i--) {
      const m = hist[i];
      if (m.quickpic && m.expiresAt && new Date(m.expiresAt).getTime() <= now) {
        // Delete IDB blob
        if (m.imgKey && typeof idbDel === 'function') idbDel(m.imgKey);
        if (m.imgKey && typeof imgCache !== 'undefined' && imgCache[m.imgKey]) {
          URL.revokeObjectURL(imgCache[m.imgKey]); delete imgCache[m.imgKey];
        }
        // If we sent this quick pic, tell the buddy to expire it too
        if (m.from === 'me' && m.id) expiredIds.push(m.id);
        hist[i] = { id: m.id, from: m.from, text: 'Quick Pic expired', ts: m.ts, delivered: m.delivered, expired: true };
        dirty = true;
      }
    }
    if (dirty) saveChatHistory(c.pubKeyB64, hist);
    // Notify buddy to expire these quick pics on their device
    if (expiredIds.length) _sendQuickPicExpiries(c.pubKeyB64, expiredIds);
  }
}
// Run purge on load and every 5 minutes
setTimeout(_purgeExpiredQuickPics, 3000);
setInterval(_purgeExpiredQuickPics, 300000);

// Send quickpic_expire signal to a buddy for message IDs that should be expired.
// Called during purge (for newly expired) and on connect (for previously expired).
function _sendQuickPicExpiries(pubKeyB64, msgIds) {
  if (!msgIds.length) return;
  const dc = typeof _getDC === 'function' ? _getDC(pubKeyB64) : null;
  if (dc && dc.readyState === 'open' && typeof _dcSend === 'function') {
    _dcSend(dc, { type: 'quickpic_expire', ids: msgIds });
  }
}

// On connect, send expiry signals for all previously expired quick pics we sent to this buddy.
// This catches retroactive cases where the relay path broke the expiry (Session XIII bug).
function _flushQuickPicExpiries(pubKeyB64) {
  const hist = typeof getChatHistory === 'function' ? getChatHistory(pubKeyB64) : [];
  const expiredIds = [];
  for (const m of hist) {
    if (m.expired && m.from === 'me' && m.text === 'Quick Pic expired' && m.id) {
      expiredIds.push(m.id);
    }
  }
  if (expiredIds.length) _sendQuickPicExpiries(pubKeyB64, expiredIds);
}

// Handle incoming quickpic_expire from a buddy — delete the photo and mark as expired.
// Matches by message ID, regardless of how the quick pic was originally stored
// (quickpic_ prefix, chatimg_ prefix, or any other storage path).
function _handleQuickPicExpire(contactPubKeyB64, msgIds) {
  if (!Array.isArray(msgIds) || !msgIds.length) return;
  const idSet = new Set(msgIds);
  const hist = typeof getChatHistory === 'function' ? getChatHistory(contactPubKeyB64) : [];
  let dirty = false;
  for (let i = hist.length - 1; i >= 0; i--) {
    const m = hist[i];
    if (!m.id || !idSet.has(m.id)) continue;
    if (m.expired) continue; // already expired
    // Delete IDB blob regardless of key prefix
    if (m.imgKey && typeof idbDel === 'function') idbDel(m.imgKey);
    if (m.imgKey && typeof imgCache !== 'undefined' && imgCache[m.imgKey]) {
      URL.revokeObjectURL(imgCache[m.imgKey]); delete imgCache[m.imgKey];
    }
    hist[i] = { id: m.id, from: m.from, text: 'Quick Pic expired', ts: m.ts, delivered: m.delivered, expired: true };
    dirty = true;
  }
  if (dirty) {
    saveChatHistory(contactPubKeyB64, hist);
    if (typeof refreshChatPopup === 'function') refreshChatPopup(contactPubKeyB64);
    if (typeof renderChatMessages === 'function') {
      const peerMsgs = document.getElementById('peer-chat-messages');
      if (peerMsgs) peerMsgs.innerHTML = renderChatMessages(contactPubKeyB64);
    }
  }
}

// Send a saved comparison as a chat reference message.
// The comparison is sent as metadata (entry IDs, source keys, transforms) — not as an image.
// The recipient opens it in their own Compare view using mutually available photos.
async function _chatSendComparison(contactPubKeyB64, comparison) {
  const targetKey = contactPubKeyB64;
  const id = typeof uuid === 'function' ? uuid() : crypto.randomUUID();
  const ts = new Date().toISOString();
  // Translate source selectors to the recipient's perspective:
  // - sender's 'entry' → 'buddy:<myPubKey>:<entryId>' (recipient sees sender's journal as buddy cache)
  // - sender's 'buddy:<recipientPubKey>:...' → 'entry' (recipient sees their own journal)
  // - sender's 'gal:...' → 'brecv:<mySl16>:...' (recipient sees sender's gallery as received)
  // - sender's 'brecv:<recipientSl16>:...' → 'gal:...' (recipient sees their own gallery)
  const myIdentity = typeof getIdentity === 'function' ? await getIdentity() : null;
  const myPub = myIdentity?.pubKeyB64 || '';
  const mySl16 = myPub.slice(0, 16);
  const recipSl16 = contactPubKeyB64.slice(0, 16);

  function _flipSrc(src, entryId) {
    if (!src || src === 'entry') return `buddy:${myPub}:${entryId || ''}`;
    const parts = src.split(':');
    if (parts[0] === 'buddy' && parts[1] === contactPubKeyB64) return 'entry'; // their own photo
    if (parts[0] === 'gal') return `brecv:${mySl16}:${parts[1]}:${parts[2]}`;
    if (parts[0] === 'brecv' && parts[1] === recipSl16) return `gal:${parts[2]}:${parts[3]}`;
    return src; // keep as-is for other buddy references
  }

  // Translate IDB keys to recipient's perspective
  function _flipKey(src, key, entryId) {
    if (!key) return null;
    // Sender's own journal photo (img_*) → recipient's buddy cache
    if ((!src || src === 'entry') && entryId) return `c_${mySl16}_${entryId}_front`;
    // Sender's cached buddy photo (c_recipSl16_*) → recipient's own journal photo
    if (key.startsWith(`c_${recipSl16}_`)) {
      const eid = entryId || key.split('_').slice(2, -1).join('_');
      const ownEntry = typeof getEntries === 'function' ? getEntries().find(e => e.id === eid) : null;
      return ownEntry?.imgFront || key;
    }
    return key; // gallery keys may match directly
  }

  const ref = {
    id: comparison.id,
    name: comparison.name,
    entryA: comparison.entryA,
    entryB: comparison.entryB,
    srcA: _flipSrc(comparison.srcA, comparison.entryA),
    srcB: _flipSrc(comparison.srcB, comparison.entryB),
    keyA: _flipKey(comparison.srcA, comparison.keyA, comparison.entryA),
    keyB: _flipKey(comparison.srcB, comparison.keyB, comparison.entryB),
    transformA: comparison.transformA,
    transformB: comparison.transformB
  };
  const text = `[comparison:${JSON.stringify(ref)}]`;
  appendChat(targetKey, { id, from: 'me', text, ts, delivered: false, failed: false });
  if (typeof syncAfterSave === 'function') syncAfterSave();
  // Close picker and re-render
  const picker = document.getElementById(_chatPhotoPickerEl || 'chatPhotoPicker');
  if (picker) picker.style.display = 'none';
  const msgsEl = document.getElementById('chatModalMsgs');
  if (msgsEl) msgsEl.style.display = '';
  refreshChatPopup(targetKey);
  try {
    const result = await p2pSendChatMessage(targetKey, text, id);
    if (result === 'direct') _updateChatMsgStatus(targetKey, id, { delivered: true });
  } catch(e) {
    _updateChatMsgStatus(targetKey, id, { failed: true });
  }
  toast('Comparison shared');
}

// Send a chat photo message.
// Wraps the photo as a chat_direct message inside an encrypted relay payload.
// DataChannel: sends directly (no size limit).
// Relay: wraps as { type:'chat_direct', id, payload } inside encrypted relay (512KB limit).
async function _sendChatPhotoMessage(contactPubKeyB64, text, messageId, photoB64, isQuickPic) {
  try {
    const identity = await getIdentity();
    if (!identity?.privKey) return false;
    const key = await deriveSharedKey(identity.privKey, contactPubKeyB64);

    // Build the inner encrypted chat payload (text + optional photo)
    const chatObj = { text };
    if (photoB64) chatObj.photo = photoB64;
    if (isQuickPic) chatObj.quickpic = true;
    const encChat = await encryptWithKey(chatObj, key);
    const encChatStr = JSON.stringify(encChat);

    // Try DataChannel first
    const conn = typeof peerConns !== 'undefined' ? peerConns.get(contactPubKeyB64) : null;
    if (conn && typeof isOnline === 'function' && isOnline(contactPubKeyB64)) {
      if (typeof _dcSend === 'function') _dcSend(conn.dc, { type: 'chat_direct', id: messageId, payload: encChatStr });
      return 'direct';
    }

    // Relay fallback — two messages:
    // 1. Text reference via chat_send (queued 24h if offline — always delivered)
    // 2. Photo blob via buddy relay (only works if peer is online via WS)
    const toHash = await _hashRawPubKey(contactPubKeyB64);

    // Always send text reference so message appears even without photo
    // Quick Pics use [quickpic:...] prefix so the receiver sets expiresAt and the purge timer fires
    const textRef = { text: isQuickPic ? `[quickpic:${messageId}]` : `[chatphoto:${messageId}]` };
    const encRef = await encryptWithKey(textRef, key);
    if (typeof _sigSend === 'function') _sigSend({ type: 'chat_send', id: messageId, toHash, payload: JSON.stringify(encRef) });

    // Try to send photo via relay (works if peer is online, silently dropped if not)
    if (photoB64 && typeof _sigSend === 'function') {
      const photoMsg = { type: 'chat_photo', id: messageId, photo: photoB64 };
      if (isQuickPic) photoMsg.quickpic = true;
      const encPhoto = await encryptWithKey(photoMsg, key);
      const relayStr = JSON.stringify(encPhoto);
      if (relayStr.length <= 524288) {
        // Live relay (delivered immediately if recipient is online via WS)
        _sigSend({ type: 'relay', to: contactPubKeyB64, kind: 'enc', payload: relayStr });
        // ALSO push through gallery_photo_relay (galleryId='_chat') so the server PERSISTS the
        // photo for up to 48h. Receiver code at p2p.js#4962 already handles galleryId='_chat'.
        // Without this, chat photos sent while recipient is offline are lost forever.
        const encPayload = { data: photoB64, mimeType: 'image/jpeg' };
        const ivBytes = crypto.getRandomValues(new Uint8Array(12));
        try {
          const ctBytes = await crypto.subtle.encrypt(
            { name: 'AES-GCM', iv: ivBytes }, key,
            new TextEncoder().encode(JSON.stringify(encPayload))
          );
          const galRelayPayload = JSON.stringify({
            iv: toUrlSafeB64(b64FromBytes(ivBytes)),
            data: toUrlSafeB64(_largeB64(ctBytes))
          });
          if (galRelayPayload.length <= 524288) {
            _sigSend({ type: 'gallery_photo_relay', toHash, galleryId: '_chat', itemId: messageId, payload: galRelayPayload });
          }
        } catch(e) { console.warn('[chat] persistent photo relay failed:', e); }
      }
    }
    // Queue photo for DC delivery when peer connects (relay is ephemeral — if peer is offline, photo is lost)
    if (photoB64) {
      const _cpQueue = typeof lsGet === 'function' ? lsGet('frj_chat_photo_queue', {}) : {};
      if (!_cpQueue[contactPubKeyB64]) _cpQueue[contactPubKeyB64] = [];
      // Cap at 10 queued photos per buddy to avoid localStorage bloat
      if (_cpQueue[contactPubKeyB64].length < 10) {
        _cpQueue[contactPubKeyB64].push({ type: 'chat_photo', id: messageId, photo: photoB64, quickpic: !!isQuickPic });
        if (typeof lsSet === 'function') lsSet('frj_chat_photo_queue', _cpQueue);
      }
    }
    return 'queued';
  } catch(e) {
    console.warn('[chat] photo message send failed:', e);
    return false;
  }
}

// ─── Chat UI ──────────────────────────────────────────────────────────────────

function renderChatPanelHtml(pubKeyB64) {
  const online = isOnline(pubKeyB64);
  const contacts = getContacts();
  const c = contacts.find(x => x.pubKeyB64 === pubKeyB64);
  const name = c?.nickname || c?.journal?.displayName || 'Buddy';
  return `
    <div class="panel" style="margin-top:8px">
      <div class="contact-journal-banner" style="font-size:11px">
        💬 Chat with ${esc(name)}
        <span style="margin-left:auto;font-size:11px;opacity:.5">${online ? '● online' : '○ offline, queued 24h'}</span>
      </div>
      <div id="peer-chat-messages" style="min-height:140px;max-height:280px;overflow-y:auto;padding:10px 12px;display:flex;flex-direction:column;gap:6px;background:var(--surface2)">
        ${renderChatMessages(pubKeyB64)}
      </div>
      <div id="peerChatPhotoPicker" style="display:none;border-top:1px solid var(--border2);max-height:45vh;overflow-y:auto;padding:10px;background:var(--surface)"></div>
      <div style="display:flex;gap:6px;padding:8px 12px;border-top:1px solid var(--border2);align-items:center">
        <button data-action="togglePeerChatPhotoPicker" data-a0="${esc(pubKeyB64)}" title="Share a photo" style="background:var(--surface2);border:1px solid var(--border2);border-radius:6px;cursor:pointer;font-size:16px;padding:4px 8px;line-height:1;flex-shrink:0">📷</button>
        <input type="text" id="peer-chat-input" class="input" placeholder="${online ? 'Type a message…' : 'Offline, message queued for 24h'}"
          style="flex:1;font-size:13px" data-chat-pub="${esc(pubKeyB64)}">
        <button class="btn btn-primary btn-sm" data-action="sendChatMsg" data-a0="${esc(pubKeyB64)}">Send</button>
      </div>
      <div style="font-size:11px;font-family:var(--mono);color:var(--muted2);text-align:center;padding:4px 12px 8px">Messages vanish after 30 days · delivered within 24h or not at all</div>
    </div>`;
}

function renderChatMessages(pubKeyB64) {
  const hist = getChatHistory(pubKeyB64);
  if (!hist.length) return `<div style="font-family:var(--mono);font-size:11px;color:var(--muted2);text-align:center;padding:20px 0">No messages yet. Say hello!</div>`;
  return hist.map(m => {
    const isMe = m.from === 'me';
    let statusIcon = '';
    if (isMe) {
      if (m.failed)       statusIcon = ` <span style="color:#f97316;font-size:11px" title="Not delivered after 24h">✕</span>`;
      else if (m.delivered) statusIcon = ` <span style="color:rgba(255,255,255,.6);font-size:11px" title="Delivered">✓</span>`;
      else                statusIcon = ` <span style="color:rgba(255,255,255,.35);font-size:11px" title="Pending…">○</span>`;
    }
    // Comparison message
    if (m.text && m.text.startsWith('[comparison:') && m.text.endsWith(']')) {
      try {
        const ref = JSON.parse(m.text.slice(12, -1));
        // Set loaded comp so resolver can access creatorSl16
        if (typeof _cmpLoadedComp !== 'undefined') _cmpLoadedComp = ref;
        const _rk = typeof _resolveComparePhotoKey === 'function' ? _resolveComparePhotoKey : () => null;
        const _fb = typeof _srcFallbackKey === 'function' ? _srcFallbackKey : () => null;
        const eA = ref.entryA && typeof getEntry === 'function' ? getEntry(ref.entryA) : null;
        const eB = ref.entryB && typeof getEntry === 'function' ? getEntry(ref.entryB) : null;
        const kA = _rk(ref.srcA, eA) || _fb(ref.srcA) || ref.keyA;
        const kB = _rk(ref.srcB, eB) || _fb(ref.srcB) || ref.keyB;
        const sA = kA && typeof imgURL === 'function' ? imgURL(kA) : null;
        const sB = kB && typeof imgURL === 'function' ? imgURL(kB) : null;
        return `<div style="display:flex;flex-direction:column;align-items:${isMe ? 'flex-end' : 'flex-start'};gap:1px">
          <div style="font-family:var(--mono);font-size:11px;color:var(--muted2)">${isMe ? 'You' : esc(m.displayName)} · ${timeAgo(m.ts)}</div>
          <div style="background:${isMe ? 'var(--green)' : 'var(--surface)'};border:1px solid ${isMe ? 'transparent' : 'var(--border2)'};padding:6px;border-radius:${isMe ? '14px 14px 3px 14px' : '14px 14px 14px 3px'};max-width:75%;cursor:pointer"
            data-action="openChatComparison" data-a0='${esc(JSON.stringify(ref))}'>
            <div style="font-size:11px;font-weight:600;color:${isMe ? '#fff' : 'var(--text)'};margin-bottom:4px">${esc(ref.name || 'Comparison')}</div>
            <div style="display:flex;gap:3px">
              <div style="width:60px;height:60px;border-radius:4px;overflow:hidden;background:var(--surface3);border:1px solid rgba(255,255,255,.15)">
                ${sA ? `<img src="${sA}" style="width:100%;height:100%;object-fit:cover">` : '<div style="width:100%;height:100%;display:flex;align-items:center;justify-content:center;font-size:9px;color:var(--muted2)">A</div>'}
              </div>
              <div style="width:60px;height:60px;border-radius:4px;overflow:hidden;background:var(--surface3);border:1px solid rgba(255,255,255,.15)">
                ${sB ? `<img src="${sB}" style="width:100%;height:100%;object-fit:cover">` : '<div style="width:100%;height:100%;display:flex;align-items:center;justify-content:center;font-size:9px;color:var(--muted2)">B</div>'}
              </div>
            </div>
            <div style="font-family:var(--mono);font-size:9px;opacity:.6;margin-top:3px;text-align:right">${timeAgo(m.ts)}${statusIcon}</div>
          </div>
        </div>`;
      } catch(e) { /* malformed — fall through to text render */ }
    }
    // Expired quick pic or expired message
    if (m.expired) {
      return `<div style="display:flex;flex-direction:column;align-items:${isMe ? 'flex-end' : 'flex-start'};gap:1px">
        <div style="font-family:var(--mono);font-size:11px;color:var(--muted2)">${isMe ? 'You' : esc(m.displayName)} · ${timeAgo(m.ts)}</div>
        <div style="background:var(--surface2);border:1px dashed var(--border2);padding:7px 11px;border-radius:12px;font-size:12px;color:var(--muted2);font-style:italic">📸 Quick Pic expired</div>
      </div>`;
    }
    // Quick Pic message (active)
    if (m.quickpic && m.imgKey) {
      // Fallback: if quickpic_xxx not found, try chatimg_xxx (pre-fix messages stored under wrong key)
      let src = typeof imgURL === 'function' ? imgURL(m.imgKey) : null;
      if (!src && m.imgKey.startsWith('quickpic_') && typeof imgURL === 'function') {
        const fallbackKey = 'chatimg_' + m.imgKey.slice(9);
        src = imgURL(fallbackKey);
      }
      const expiresMs = m.expiresAt ? new Date(m.expiresAt).getTime() - Date.now() : 0;
      const minsLeft = Math.max(0, Math.ceil(expiresMs / 60000));
      const countdownText = minsLeft > 0 ? `⏱ ${minsLeft}m left` : '⏱ expiring…';
      return `<div style="display:flex;flex-direction:column;align-items:${isMe ? 'flex-end' : 'flex-start'};gap:1px">
        <div style="font-family:var(--mono);font-size:11px;color:var(--muted2)">${isMe ? 'You' : esc(m.displayName)} · ${timeAgo(m.ts)}</div>
        <div style="background:${isMe ? '#b45309' : '#92400e'};border:2px solid #f59e0b;padding:4px;border-radius:${isMe ? '14px 14px 3px 14px' : '14px 14px 14px 3px'};max-width:60%;position:relative">
          ${src
            ? `<img src="${src}" style="width:100%;max-height:180px;object-fit:contain;border-radius:10px;display:block" data-no-context="1" data-no-drag="1">`
            : `<div style="width:100px;height:75px;border-radius:10px;background:rgba(0,0,0,.3);display:flex;align-items:center;justify-content:center;font-size:10px;color:#fbbf24">Loading...</div>`}
          <div style="display:flex;justify-content:space-between;align-items:center;padding:3px 4px 0;font-size:10px;color:#fcd34d">
            <span>📸 Quick Pic</span>
            <span>${countdownText}</span>
          </div>
          <div style="font-size:9px;opacity:.5;text-align:right;padding:0 4px">${timeAgo(m.ts)}${statusIcon}</div>
        </div>
      </div>`;
    }
    // Photo message
    const photoKey = m.imgKey || (m.text && m.text.startsWith('[photo:') && m.text.endsWith(']') ? m.text.slice(7, -1) : null);
    if (photoKey) {
      const src = typeof imgURL === 'function' ? imgURL(photoKey) : null;
      return `<div style="display:flex;flex-direction:column;align-items:${isMe ? 'flex-end' : 'flex-start'};gap:1px">
        <div style="font-family:var(--mono);font-size:11px;color:var(--muted2)">${isMe ? 'You' : esc(m.displayName)} · ${timeAgo(m.ts)}</div>
        <div style="background:${isMe ? 'var(--green)' : 'var(--surface)'};border:1px solid ${isMe ? 'transparent' : 'var(--border2)'};padding:4px;border-radius:${isMe ? '14px 14px 3px 14px' : '14px 14px 14px 3px'};max-width:60%">
          ${src
            ? `<img src="${src}" style="width:100%;max-height:180px;object-fit:contain;border-radius:10px;display:block;cursor:pointer" data-action="openLb" data-a0="${src}">`
            : `<div style="width:100px;height:75px;border-radius:10px;background:var(--surface2);display:flex;align-items:center;justify-content:center;font-size:10px;color:var(--muted2)">Loading...</div>`}
          <div style="font-size:10px;opacity:.6;margin-top:3px;text-align:right;padding:0 4px">${timeAgo(m.ts)}${statusIcon}</div>
        </div>
      </div>`;
    }
    return `<div style="display:flex;flex-direction:column;align-items:${isMe ? 'flex-end' : 'flex-start'};gap:1px">
      <div style="font-family:var(--mono);font-size:11px;color:var(--muted2)">${isMe ? 'You' : esc(m.displayName)} · ${timeAgo(m.ts)}</div>
      <div style="background:${isMe ? 'var(--green)' : 'var(--surface)'};color:${isMe ? '#fff' : 'var(--text)'};border:1px solid ${isMe ? 'transparent' : 'var(--border2)'};padding:7px 11px;border-radius:${isMe ? '14px 14px 3px 14px' : '14px 14px 14px 3px'};font-size:13px;max-width:85%;word-break:break-word;line-height:1.4">${esc(m.text)}${statusIcon}</div>
    </div>`;
  }).join('');
}

async function sendChatMsg(pubKeyB64) {
  const input = document.getElementById('peer-chat-input');
  const text = (input?.value || '').trim();
  if (!text) return;
  input.value = '';

  const id    = typeof uuid === 'function' ? uuid() : crypto.randomUUID();
  const myName = (await getIdentity())?.displayName || 'You';
  appendChat(pubKeyB64, { id, from: 'me', displayName: myName, text, ts: new Date().toISOString(), delivered: false, failed: false });
  refreshChatPanel(pubKeyB64);

  const result = await p2pSendChatMessage(pubKeyB64, text, id);
  if (result === 'direct') {
    // ack will come back via chat_direct_ack → markChatDelivered → _chatNotify
  } else if (!result) {
    // failed immediately (no server connection either)
    _updateChatMsgStatus(pubKeyB64, id, { failed: true });
  }
  // 'queued' → stays pending until chat_ack or chat_expired from server
}


function ciBadgeClass(ci) { return ''; }

function timeAgo(iso) {
  if (!iso) return 'never';
  const diff = Date.now() - new Date(iso).getTime();
  const m = Math.floor(diff / 60000);
  if (m < 1) return 'just now';
  if (m < 60) return m + 'm ago';
  const h = Math.floor(m / 60);
  if (h < 24) return h + 'h ago';
  return Math.floor(h / 24) + 'd ago';
}
