'use strict';
// ═══════════════════════════════════════════════
// js/gallery.js — Gallery system
// Folders with per-folder permissions, buddy sharing, watermarks, notes
// ═══════════════════════════════════════════════

// ── Page state ────────────────────────────────────────────────────────────────
let _galView   = 'list'; // 'list' | 'folder'
let _galTab    = 'mine'; // 'mine' | 'received'
let _galOpenId     = null; // own gallery id (when _galView==='folder' && _galTab==='mine')
let _galOpenSender = null; // senderSlice16 (when _galView==='folder' && _galTab==='received')
let _galOpenGalId  = null; // galleryId     (when _galView==='folder' && _galTab==='received')
let _galFilterSender = null; // senderSlice16 to filter received list by (null = show all)

// ── Permissions helpers ───────────────────────────────────────────────────────
function galleryContactCanAccess(gallery, contact) {
  if (!gallery || !contact) return false;
  const { tiers = [], contacts = [] } = gallery.permissions || {};
  if (contacts.includes(contact.pubKeyB64)) return true;
  if (tiers.includes('trusted') && contact.trusted) return true;
  if (tiers.includes('buddy'))  return true; // all contacts = all buddies
  return false;
}

function _galPermLabel(gallery) {
  const { tiers = [], contacts = [] } = gallery.permissions || {};
  const parts = [];
  if (tiers.includes('buddy'))   parts.push('All buddies');
  if (tiers.includes('trusted')) parts.push('All trusted');
  if (contacts.length) parts.push(`${contacts.length} contact${contacts.length > 1 ? 's' : ''}`);
  return parts.length ? parts.join(' + ') : 'Private';
}

// ── Render entry point (called by tab()) ──────────────────────────────────────
function renderGallery() {
  const el = document.getElementById('page-gallery');
  if (!el) return;
  if (_galView === 'folder') { _renderGalleryFolder(el); return; }
  _renderGalleryList(el);
}

// ── Gallery list (mine + received tabs) ───────────────────────────────────────
function _renderGalleryList(el) {
  const galleries = getGalleries();
  const received  = getGalleryReceived();
  const rxCount   = Object.values(received).reduce((n, byGal) => n + Object.keys(byGal).length, 0);

  // Build sender filter options for received tab
  const contacts = typeof getContacts === 'function' ? getContacts() : [];
  const senders = new Set();
  for (const sl16 of Object.keys(received)) senders.add(sl16);
  const filterHtml = _galTab === 'received' && senders.size > 1 ? `
    <div style="display:flex;align-items:center;gap:8px;margin-bottom:12px">
      <span style="font-family:var(--mono);font-size:10px;color:var(--muted2);text-transform:uppercase;letter-spacing:.06em">Filter</span>
      <select class="input" style="flex:1;font-size:12px;max-width:220px" data-action-change="galFilterSender">
        <option value="">All buddies</option>
        ${[...senders].map(sl16 => {
          const c = contacts.find(x => x.pubKeyB64?.startsWith(sl16));
          const name = c?.journal?.displayName || c?.nickname || sl16.slice(0, 8);
          return `<option value="${esc(sl16)}" ${_galFilterSender === sl16 ? 'selected' : ''}>${esc(name)}</option>`;
        }).join('')}
      </select>
      ${_galFilterSender ? `<button class="btn btn-ghost btn-sm" style="font-size:11px" data-action="galClearFilter">Clear</button>` : ''}
    </div>` : '';

  el.innerHTML = `
    <div class="page-hdr">
      <h1>Gallery</h1>
      <button class="btn btn-primary btn-sm" data-action="showCreateGalleryModal">+ New folder</button>
    </div>
    <div style="display:flex;gap:8px;margin-bottom:1rem;flex-wrap:wrap">
      <button class="btn btn-sm ${_galTab==='mine'?'btn-primary':''}" data-action="switchGalleryTab" data-a0="mine">My galleries (${galleries.length})</button>
      <button class="btn btn-sm ${_galTab==='received'?'btn-primary':''}" data-action="switchGalleryTab" data-a0="received">Received (${rxCount})</button>
      ${(() => {
        const bm = typeof getBookmarks === 'function' ? getBookmarks() : {};
        const bmCount = Object.keys(bm).filter(k => k.startsWith('recv:') || k.startsWith('gal:')).length;
        return bmCount ? `<button class="btn btn-sm ${_galTab==='bookmarks'?'btn-primary':'btn-ghost'}" data-action="openBookmarksFolder">🔖 (${bmCount})</button>` : '';
      })()}
    </div>
    ${filterHtml}
    <div id="gal-list-body"></div>`;

  // Wire filter dropdown (can't use data-action on select change)
  const filterSel = el.querySelector('[data-action-change="galFilterSender"]');
  if (filterSel) {
    filterSel.addEventListener('change', () => {
      _galFilterSender = filterSel.value || null;
      renderGallery();
    });
  }

  const body = el.querySelector('#gal-list-body');
  if (_galTab === 'mine') {
    _renderMineList(body, galleries);
  } else {
    _renderReceivedList(body, received);
  }
}

function _renderMineList(body, galleries) {
  if (!galleries.length) {
    body.innerHTML = `<div class="panel" style="text-align:center;padding:2rem;color:var(--muted)">
      <p style="margin-bottom:12px">No galleries yet. Create a folder to share photos with buddies.</p>
      <button class="btn btn-primary btn-sm" data-action="showCreateGalleryModal">Create gallery</button>
    </div>`;
    return;
  }
  body.innerHTML = `<div class="gal-grid">
    ${galleries.map(g => _galCard(g)).join('')}
  </div>`;
}

function _galCard(g) {
  const cover = g.items.length
    ? `<canvas class="photo-canvas" style="width:100%;display:block"
        data-img-key="${esc(g.items[0].imgKey)}"></canvas>`
    : `<div style="width:100%;aspect-ratio:3/4;background:var(--surface2);display:flex;align-items:center;justify-content:center;color:var(--muted2);font-size:28px">◻</div>`;
  // Count buddy bookmarks across all items in this gallery
  const _bmRcvdCard = typeof getBookmarksReceived === 'function' ? getBookmarksReceived() : {};
  let _bmCardCount = 0;
  for (const item of g.items) {
    _bmCardCount += (_bmRcvdCard[`${g.id}:${item.id}`] || []).length;
  }
  const bmBadge = _bmCardCount ? `<span style="position:absolute;top:6px;right:6px;font-size:13px;background:rgba(255,255,255,.85);border-radius:6px;padding:2px 6px;line-height:1;box-shadow:0 1px 3px rgba(0,0,0,.15);display:flex;align-items:center;gap:2px">🔖<span style="font-family:var(--mono);font-size:10px">${_bmCardCount}</span></span>` : '';
  return `<div class="gal-card" data-action="openGalleryFolder" data-a0="${esc(g.id)}" data-a1="mine">
    <div class="gal-card-cover" style="position:relative">${cover}${bmBadge}</div>
    <div class="gal-card-body">
      <div class="gal-card-title">${esc(g.title)}</div>
      <div class="gal-card-meta">${g.items.length} photo${g.items.length !== 1 ? 's' : ''} · ${esc(_galPermLabel(g))}</div>
    </div>
  </div>`;
}

function _renderReceivedList(body, received) {
  // Bookmarked photos virtual folder
  const bookmarks = typeof getBookmarks === 'function' ? getBookmarks() : {};
  const recvBookmarkKeys = Object.keys(bookmarks).filter(k => k.startsWith('recv:'));
  let bookmarksCard = '';
  if (recvBookmarkKeys.length && !_galFilterSender) {
    let bmCount = 0;
    let coverKey = null;
    for (const key of recvBookmarkKeys) {
      const [, sl16, galId, itemId] = key.split(':');
      const idbKey = received[sl16]?.[galId]?.items?.[itemId]?.idbKey;
      if (idbKey) { bmCount++; if (!coverKey) coverKey = idbKey; }
    }
    if (bmCount > 0) {
      const cover = coverKey
        ? `<canvas class="photo-canvas" style="width:100%;display:block" data-img-key="${esc(coverKey)}"></canvas>`
        : `<div style="width:100%;aspect-ratio:3/4;background:var(--accent2);display:flex;align-items:center;justify-content:center;font-size:28px">🔖</div>`;
      bookmarksCard = `<div class="gal-card" data-action="openBookmarksFolder" style="cursor:pointer">
        <div class="gal-card-cover">${cover}</div>
        <div class="gal-card-body">
          <div class="gal-card-title">🔖 Bookmarked</div>
          <div class="gal-card-meta">${bmCount} photo${bmCount !== 1 ? 's' : ''}</div>
        </div>
      </div>`;
    }
  }

  const entries = [];
  for (const [senderSlice16, byGal] of Object.entries(received)) {
    if (_galFilterSender && senderSlice16 !== _galFilterSender) continue;
    for (const [galleryId, galData] of Object.entries(byGal)) {
      entries.push({ senderSlice16, galleryId, galData });
    }
  }

  // Left galleries — show with rejoin option
  const left = typeof getGalleryLeft === 'function' ? getGalleryLeft() : {};
  const leftEntries = [];
  for (const [senderSlice16, byGal] of Object.entries(left)) {
    for (const [galleryId, galData] of Object.entries(byGal)) {
      leftEntries.push({ senderSlice16, galleryId, galData });
    }
  }

  if (!entries.length && !leftEntries.length && !bookmarksCard) {
    body.innerHTML = `<div class="panel" style="text-align:center;padding:2rem;color:var(--muted)">
      <p>No received galleries yet. When a buddy shares a gallery with you, it appears here.</p>
    </div>`;
    return;
  }
  body.innerHTML = `<div class="gal-grid">
    ${bookmarksCard}
    ${entries.map(({ senderSlice16, galleryId, galData }) => {
      const itemCount = Object.keys(galData.items || {}).length;
      const firstItem = Object.values(galData.items || {})[0];
      const cover = firstItem
        ? `<canvas class="photo-canvas" style="width:100%;display:block"
            data-img-key="${esc(firstItem.idbKey)}"></canvas>`
        : `<div style="width:100%;aspect-ratio:3/4;background:var(--surface2);display:flex;align-items:center;justify-content:center;color:var(--muted2);font-size:28px">◻</div>`;
      const contact = getContacts().find(c => c.pubKeyB64.startsWith(senderSlice16));
      const senderName = contact?.journal?.displayName || contact?.pubKeyB64?.slice(0,8) || senderSlice16.slice(0,8);
      return `<div class="gal-card" data-action="openReceivedGallery" data-a0="${esc(senderSlice16)}" data-a1="${esc(galleryId)}">
        <div class="gal-card-cover">${cover}</div>
        <div class="gal-card-body">
          <div class="gal-card-title">${esc(galData.title || 'Gallery')}</div>
          <div class="gal-card-meta">${itemCount} photo${itemCount !== 1 ? 's' : ''} · from ${esc(senderName)}</div>
        </div>
      </div>`;
    }).join('')}
  </div>
  ${leftEntries.length ? `
    <div style="margin-top:1.5rem;padding-top:1rem;border-top:1px solid var(--border2)">
      <div style="font-family:var(--mono);font-size:11px;font-weight:700;letter-spacing:.06em;text-transform:uppercase;color:var(--muted2);margin-bottom:10px">Left galleries</div>
      ${leftEntries.map(({ senderSlice16, galleryId, galData }) => {
        const contact = getContacts().find(c => c.pubKeyB64.startsWith(senderSlice16));
        const senderName = contact?.journal?.displayName || contact?.pubKeyB64?.slice(0,8) || senderSlice16.slice(0,8);
        return `<div class="panel" style="display:flex;align-items:center;gap:12px;padding:10px 14px;margin-bottom:6px">
          <div style="flex:1;min-width:0">
            <div style="font-size:13px;font-weight:600">${esc(galData.title || 'Gallery')}</div>
            <div style="font-family:var(--mono);font-size:11px;color:var(--muted2)">from ${esc(senderName)}</div>
          </div>
          <button class="btn btn-ghost btn-sm" data-action="rejoinGallery" data-a0="${esc(senderSlice16)}" data-a1="${esc(galleryId)}">Rejoin</button>
        </div>`;
      }).join('')}
    </div>
  ` : ''}`;
}

// ── Gallery folder view ───────────────────────────────────────────────────────
function _renderGalleryFolder(el) {
  if (_galTab === 'bookmarks') {
    _renderBookmarksFolder(el);
  } else if (_galTab === 'mine') {
    _renderMineFolder(el);
  } else {
    _renderReceivedFolder(el);
  }
}

function _renderBookmarksFolder(el) {
  const bookmarks = typeof getBookmarks === 'function' ? getBookmarks() : {};
  const received  = typeof getGalleryReceived === 'function' ? getGalleryReceived() : {};
  const contacts  = typeof getContacts === 'function' ? getContacts() : [];

  const items = [];
  const galleries = typeof getGalleries === 'function' ? getGalleries() : [];

  for (const [key, bmData] of Object.entries(bookmarks)) {
    if (key.startsWith('recv:')) {
      const [, sl16, galId, itemId] = key.split(':');
      const meta = received[sl16]?.[galId]?.items?.[itemId];
      if (!meta?.idbKey) continue;
      const c = contacts.find(x => x.pubKeyB64?.startsWith(sl16));
      const senderName = c?.journal?.displayName || c?.nickname || sl16.slice(0, 8);
      items.push({ imgKey: meta.idbKey, title: meta.title || '', label: senderName, action: 'openGalleryImageModal', a0: itemId, a1: 'received', a2: `${sl16}:${galId}`, bookmarkedAt: bmData.bookmarkedAt });
    } else if (key.startsWith('gal:')) {
      const [, galId, itemId] = key.split(':');
      const g = galleries.find(x => x.id === galId);
      const item = g?.items?.find(i => i.id === itemId);
      if (!item?.imgKey) continue;
      items.push({ imgKey: item.imgKey, title: item.title || '', label: g?.title || 'Gallery', action: 'openGalleryImageModal', a0: itemId, a1: 'mine', a2: '', bookmarkedAt: bmData.bookmarkedAt });
    }
  }
  items.sort((a, b) => (b.bookmarkedAt || '').localeCompare(a.bookmarkedAt || ''));

  // Bookmarked comparisons
  const cmpItems = [];
  const savedCmps = typeof getSavedComparisons === 'function' ? getSavedComparisons() : [];
  for (const [key, bmData] of Object.entries(bookmarks)) {
    if (!key.startsWith('comparison:')) continue;
    const cmpId = key.slice(11);
    const cmp = savedCmps.find(c => c.id === cmpId);
    if (!cmp) continue;
    if (typeof _comparisonInvolvesMe === 'function' && !_comparisonInvolvesMe(cmp)) continue;
    if (typeof _cmpLoadedComp !== 'undefined') _cmpLoadedComp = cmp;
    const _rk = typeof _resolveComparePhotoKey === 'function' ? _resolveComparePhotoKey : () => null;
    const _fb = typeof _srcFallbackKey === 'function' ? _srcFallbackKey : () => null;
    const eA = cmp.entryA ? (typeof getEntry === 'function' ? getEntry(cmp.entryA) : null) : null;
    const eB = cmp.entryB ? (typeof getEntry === 'function' ? getEntry(cmp.entryB) : null) : null;
    const kA = _rk(cmp.srcA, eA) || _fb(cmp.srcA) || cmp.keyA;
    const kB = _rk(cmp.srcB, eB) || _fb(cmp.srcB) || cmp.keyB;
    cmpItems.push({ cmp, kA, kB, bookmarkedAt: bmData.bookmarkedAt });
  }
  cmpItems.sort((a, b) => (b.bookmarkedAt || '').localeCompare(a.bookmarkedAt || ''));

  const totalCount = items.length + cmpItems.length;

  el.innerHTML = `
    <div class="page-hdr">
      <button class="btn btn-sm" data-action="backToGalleryList" style="flex-shrink:0">← Back</button>
      <h1 style="flex:1">🔖 Bookmarked</h1>
    </div>
    <div style="margin-bottom:12px;font-size:12px;color:var(--muted)">
      ${totalCount} bookmarked item${totalCount !== 1 ? 's' : ''}
    </div>
    ${items.length ? `<div class="gal-grid">
      ${items.map(it => `
        <div class="gal-item" data-action="${it.action}" data-a0="${esc(it.a0)}" data-a1="${esc(it.a1)}"${it.a2 ? ` data-a2="${esc(it.a2)}"` : ''}>
          <canvas class="photo-canvas" data-img-key="${esc(it.imgKey)}"></canvas>
          <div class="gal-item-title" style="font-size:10px">${esc(it.title)} <span style="color:var(--muted2)">· ${esc(it.label)}</span></div>
        </div>
      `).join('')}
    </div>` : ''}
    ${cmpItems.length ? `
      <div style="font-family:var(--mono);font-size:11px;font-weight:700;letter-spacing:.06em;text-transform:uppercase;color:var(--muted2);margin:${items.length ? '1.5rem' : '0'} 0 10px">Comparisons</div>
      <div class="gal-grid">
        ${cmpItems.map(({ cmp, kA, kB }) => {
          const srcA = kA ? imgURL(kA) : null;
          const srcB = kB ? imgURL(kB) : null;
          const tA = cmp.transformA || {};
          const tB = cmp.transformB || {};
          const tfA = `transform:${typeof _scaleCmpTransformCSS === 'function' ? _scaleCmpTransformCSS(tA) : 'none'}`;
          const tfB = `transform:${typeof _scaleCmpTransformCSS === 'function' ? _scaleCmpTransformCSS(tB) : 'none'}`;
          const _cmpThumb = (src, key, tf) => src
            ? `<img src="${src}" style="position:absolute;inset:0;width:100%;height:100%;object-fit:contain;${tf};transform-origin:center center;pointer-events:none">`
            : (key ? `<canvas class="photo-canvas" data-img-key="${esc(key)}" style="position:absolute;inset:0;width:100%;height:100%;display:block;${tf};transform-origin:center center"></canvas>` : '');
          return `<div class="gal-item" data-action="loadComparison" data-a0="${esc(cmp.id)}" style="cursor:pointer">
            <div style="display:flex;gap:2px;aspect-ratio:3/2;overflow:hidden">
              <div style="flex:1;overflow:hidden;background:var(--surface3);position:relative">${_cmpThumb(srcA, kA, tfA)}</div>
              <div style="flex:1;overflow:hidden;background:var(--surface3);position:relative">${_cmpThumb(srcB, kB, tfB)}</div>
            </div>
            <div class="gal-item-title" style="font-size:10px">${esc(cmp.name)}</div>
            <div style="font-family:var(--mono);font-size:9px;color:var(--muted2);padding:0 6px 4px">${esc(cmp.descA || '?')} vs ${esc(cmp.descB || '?')}</div>
          </div>`;
        }).join('')}
      </div>
    ` : ''}
    ${!totalCount ? '<div class="empty"><p>No bookmarks yet. Bookmark a photo or comparison to add it here.</p></div>' : ''}`;
}

function _renderMineFolder(el) {
  const galleries = getGalleries();
  const g = galleries.find(x => x.id === _galOpenId);
  if (!g) { backToGalleryList(); return; }

  el.innerHTML = `
    <div class="page-hdr">
      <button class="btn btn-sm" data-action="backToGalleryList" style="flex-shrink:0">← Back</button>
      <h1 style="flex:1;cursor:pointer" data-action="renameGallery" data-a0="${esc(g.id)}" title="Click to rename">${esc(g.title)}</h1>
      <button class="btn btn-sm" data-action="editGalleryPermissions" data-a0="${esc(g.id)}">Permissions</button>
    </div>
    <div style="margin-bottom:12px;font-size:12px;color:var(--muted);display:flex;align-items:center;gap:10px">
      <span>Shared with: <strong>${esc(_galPermLabel(g))}</strong></span>
      <span style="color:var(--muted2)">·</span>
      <span style="color:var(--amber)">Images are cached on buddy devices</span>
    </div>
    <div class="gal-photo-grid" id="gal-folder-grid"></div>
    <div style="display:flex;gap:8px;margin-top:1rem">
      <button class="btn btn-primary btn-sm" data-action="addPhotoToGallery" data-a0="${esc(g.id)}">+ Add photo</button>
      <button class="btn btn-danger btn-sm" data-action="deleteGalleryConfirm" data-a0="${esc(g.id)}">Delete folder</button>
    </div>`;

  const grid = el.querySelector('#gal-folder-grid');
  if (!g.items.length) {
    grid.innerHTML = `<p style="color:var(--muted);font-size:13px">No photos yet. Add one!</p>`;
    return;
  }
  const _bnMine = getGalleryBuddyNotes();
  const _bmRcvd = typeof getBookmarksReceived === 'function' ? getBookmarksReceived() : {};
  const _bmOwn  = typeof getBookmarks === 'function' ? getBookmarks() : {};
  grid.innerHTML = g.items.map(item => {
    const noteCount = (_bnMine[g.id]?.[item.id] || []).length;
    const kotCount = (typeof getGalleryKotsForItem === 'function' ? getGalleryKotsForItem(g.id, item.id) : []).length;
    const bmKey = `${g.id}:${item.id}`;
    const buddyBms = (_bmRcvd[bmKey] || []).length;
    const ownBm = !!_bmOwn[`gal:${g.id}:${item.id}`];
    const bmTotal = buddyBms + (ownBm ? 1 : 0);
    return `<div class="gal-item" data-action="openGalleryImageModal" data-a0="${esc(item.id)}" data-a1="mine">
      <canvas class="photo-canvas" data-img-key="${esc(item.imgKey)}"></canvas>
      ${bmTotal ? `<span class="gal-note-badge" style="top:4px;left:4px;background:var(--accent);color:#fff" title="${bmTotal} bookmark${bmTotal !== 1 ? 's' : ''}">🔖${bmTotal > 1 ? bmTotal : ''}</span>` : ''}
      ${noteCount ? `<span class="gal-note-badge" style="top:4px;left:${bmTotal ? '32px' : '4px'}" title="${noteCount} comment${noteCount !== 1 ? 's' : ''}">💬${noteCount > 1 ? noteCount : ''}</span>` : ''}
      ${kotCount ? `<span class="gal-note-badge" style="top:4px;left:auto;right:4px" title="${kotCount} KOT${kotCount !== 1 ? 's' : ''}">✊${kotCount > 1 ? kotCount : ''}</span>` : ''}
      ${item.title ? `<div class="gal-item-title">${esc(item.title)}</div>` : ''}
      ${item.note ? `<div class="gal-item-note">${esc(item.note)}</div>` : ''}
      <button class="gal-item-remove" data-action="removeGalleryItemConfirm" data-a0="${esc(item.id)}" data-a1="${esc(g.id)}">×</button>
    </div>`;
  }).join('');
  // Photo canvases auto-render via MutationObserver in photos.js
}

function _renderReceivedFolder(el) {
  const recv = getGalleryReceived();
  const senderGals = recv[_galOpenSender] || {};
  const galData = senderGals[_galOpenGalId];
  if (!galData) { backToGalleryList(); return; }

  const contact = getContacts().find(c => c.pubKeyB64.startsWith(_galOpenSender));
  const senderName = contact?.journal?.displayName || _galOpenSender?.slice(0,8) || '?';
  const items = Object.entries(galData.items || {});

  el.innerHTML = `
    <div class="page-hdr">
      <button class="btn btn-sm" data-action="backToGalleryList" style="flex-shrink:0">← Back</button>
      <h1 style="flex:1">${esc(galData.title || 'Gallery')}</h1>
    </div>
    <div style="margin-bottom:12px;font-size:12px;color:var(--muted);display:flex;align-items:center;gap:10px">
      <span>From: <strong>${esc(senderName)}</strong></span>
      <span style="color:var(--muted2)">·</span>
      <span style="color:var(--muted2)">Cached on your device</span>
      <button class="btn btn-sm" style="margin-left:auto" data-action="leaveReceivedGallery" data-a0="${esc(_galOpenSender)}" data-a1="${esc(_galOpenGalId)}">Leave</button>
    </div>
    <div class="gal-photo-grid" id="gal-recv-grid"></div>`;

  const grid = el.querySelector('#gal-recv-grid');
  if (!items.length) {
    grid.innerHTML = `<p style="color:var(--muted);font-size:13px">Waiting for photos…</p>`;
    return;
  }
  grid.innerHTML = items.map(([itemId, meta]) => {
    const kotSent = typeof getSentGalleryKot === 'function' && getSentGalleryKot(_galOpenSender, _galOpenGalId, itemId);
    const noteSent = typeof getGallerySentNote === 'function' && getGallerySentNote(_galOpenSender, _galOpenGalId, itemId);
    const _ownBm2 = typeof getBookmarks === 'function' ? getBookmarks() : {};
    const _isBm = !!_ownBm2[`recv:${_galOpenSender}:${_galOpenGalId}:${itemId}`];
    return `<div class="gal-item" data-action="openGalleryImageModal" data-a0="${esc(itemId)}" data-a1="received"
      data-a2="${esc(_galOpenSender + ':' + _galOpenGalId)}">
      <canvas class="photo-canvas"
        data-img-key="${esc(meta.idbKey)}"></canvas>
      ${_isBm ? `<span class="gal-note-badge" style="left:4px;top:4px;background:var(--accent);color:#fff" title="Bookmarked">🔖</span>` : ''}
      ${kotSent ? `<span class="gal-note-badge" title="KOT'd">✊</span>` : ''}
      ${noteSent ? `<span class="gal-note-badge" style="left:auto;right:4px" title="Commented">💬</span>` : ''}
      ${meta.title ? `<div class="gal-item-title">${esc(meta.title)}</div>` : ''}
      ${meta.note ? `<div class="gal-item-note">${esc(meta.note)}</div>` : ''}
    </div>`;
  }).join('');
  // Photo canvases auto-render via MutationObserver in photos.js
}

// ── Gallery image modal ───────────────────────────────────────────────────────
function openGalleryImageModal(itemId, side, senderGalKey) {
  // side = 'mine' | 'received'
  // senderGalKey = 'senderSlice16:galleryId' (received only)
  let imgKey, title, ownerNote, privateNoteKey, isMine;
  if (side === 'mine') {
    isMine = true;
    const g = getGalleries().find(x => x.id === _galOpenId);
    const item = g?.items.find(i => i.id === itemId);
    if (!item) return;
    imgKey       = item.imgKey;
    title        = item.title || '';
    ownerNote    = item.note || '';
    privateNoteKey = null; // owner edits note directly on item
  } else {
    isMine = false;
    const [senderSlice16, galleryId] = (senderGalKey || `${_galOpenSender}:${_galOpenGalId}`).split(':');
    const recv = getGalleryReceived();
    const meta = recv[senderSlice16]?.[galleryId]?.items?.[itemId];
    if (!meta) return;
    imgKey         = meta.idbKey;
    title          = meta.title || '';
    ownerNote      = meta.note || '';
    privateNoteKey = imgKey; // recipient's private note keyed by idbKey
  }

  const m = document.getElementById('galImgModal');
  if (!m) return;
  m.querySelector('#galimg-photo').innerHTML =
    `<canvas class="photo-canvas" data-img-key="${esc(imgKey)}" data-lightbox="1"
      style="width:100%;max-height:60vh;object-fit:contain;border-radius:8px;display:block;cursor:zoom-in"></canvas>`;
  m.querySelector('#galimg-title').value      = title;
  m.querySelector('#galimg-title').readOnly   = !isMine;
  m.querySelector('#galimg-save-title').style.display = isMine ? '' : 'none';
  m.querySelector('#galimg-item-id').value    = itemId;
  m.querySelector('#galimg-gallery-id').value = isMine ? _galOpenId : '';
  m.querySelector('#galimg-is-mine').value    = isMine ? '1' : '0';
  const editSection = m.querySelector('#galimg-edit-section');
  if (editSection) editSection.style.display = isMine ? 'flex' : 'none';

  // Bookmark section — create element if missing (old cached HTML)
  let starSection = m.querySelector('#galimg-star-section');
  if (!starSection) {
    starSection = document.createElement('div');
    starSection.id = 'galimg-star-section';
    const noteEl2 = m.querySelector('#galimg-note');
    if (noteEl2) noteEl2.parentNode.insertBefore(starSection, noteEl2.nextSibling);
    else m.querySelector('.modal-body')?.appendChild(starSection);
  }
  if (starSection) {
    if (isMine) {
      // Owner view — show who bookmarked this photo
      const bmRcvd = typeof getBookmarksReceived === 'function' ? getBookmarksReceived() : {};
      const bmKey = `${_galOpenId}:${itemId}`;
      const bms = bmRcvd[bmKey] || [];
      // Own bookmark toggle
      const ownBookmarks = typeof getBookmarks === 'function' ? getBookmarks() : {};
      const ownBmKey = `gal:${_galOpenId}:${itemId}`;
      const ownBookmarked = !!ownBookmarks[ownBmKey];
      starSection.style.display = '';
      starSection.innerHTML = `<div style="display:flex;align-items:center;gap:6px;flex-wrap:wrap">
        <button class="btn btn-sm ${ownBookmarked ? '' : 'btn-ghost'}" data-action="toggleBookmark" data-a0="${esc(ownBmKey)}" style="${ownBookmarked ? 'background:var(--accent2);border-color:var(--accent);color:var(--accent)' : ''}">${ownBookmarked ? '🔖 Bookmarked' : '🔖 Bookmark'}</button>
        ${bms.length ? `<span style="font-family:var(--mono);font-size:11px;color:var(--muted)">· ${bms.length} bookmark${bms.length !== 1 ? 's' : ''}</span>
        ${bms.map(s => {
          const c = (typeof getContacts === 'function' ? getContacts() : []).find(x => x.pubKeyB64?.startsWith(s.senderSlice16));
          const name = c?.journal?.displayName || c?.nickname || s.senderSlice16.slice(0, 8);
          return `<span style="font-size:12px;padding:2px 8px;border-radius:10px;background:var(--accent2);border:1px solid rgba(37,99,235,.2);color:var(--accent)">${esc(name)}</span>`;
        }).join('')}` : ''}
      </div>`;
    } else {
      // Received photo — show bookmark button (trusted buddies can also notify owner)
      const [sl16, galId] = (senderGalKey || `${_galOpenSender}:${_galOpenGalId}`).split(':');
      const recv = typeof getGalleryReceived === 'function' ? getGalleryReceived() : {};
      const ownerPub = recv[sl16]?.[galId]?.senderPubKey || '';
      const contacts = typeof getContacts === 'function' ? getContacts() : [];
      const contact = ownerPub
        ? contacts.find(c => c.pubKeyB64 === ownerPub)
        : contacts.find(c => c.pubKeyB64?.startsWith(sl16));
      const mutualTrust = contact?.trusted && contact?.senderTrusts;
      const bookmarks = typeof getBookmarks === 'function' ? getBookmarks() : {};
      const bmKey = `recv:${sl16}:${galId}:${itemId}`;
      const alreadyBookmarked = !!bookmarks[bmKey];
      starSection.style.display = '';
      starSection.innerHTML = alreadyBookmarked
        ? `<button class="btn btn-sm" disabled style="background:var(--accent2);border-color:var(--accent);color:var(--accent)">🔖 Bookmarked</button>`
        : `<button class="btn btn-sm btn-ghost" data-action="bookmarkReceivedPhoto" data-a0="${esc(sl16)}" data-a1="${esc(galId)}" data-a2="${esc(itemId)}" data-a3="${mutualTrust ? '1' : '0'}">🔖 Bookmark</button>`;
    }
  }

  // Note field: owner writes caption (travels with image), recipient sees it read-only
  const noteEl  = m.querySelector('#galimg-note');
  const noteLabel = m.querySelector('#galimg-note-label');
  const sendNoteSection = m.querySelector('#galimg-send-note-section');
  const buddyNotesSection = m.querySelector('#galimg-buddy-notes-section');
  if (isMine) {
    noteEl.value    = ownerNote;
    noteEl.readOnly = false;
    noteEl.placeholder = 'Add a note (buddies will see this)…';
    if (noteLabel) noteLabel.innerHTML = 'Note <span style="color:var(--muted2);font-weight:400">(visible to buddies)</span>';
    m.querySelector('#galimg-note-key').value = '';
    m.querySelector('#galimg-note-save-btn').style.display = '';
    m.querySelector('#galimg-sender-gal-key').value = '';
    if (sendNoteSection) sendNoteSection.style.display = 'none';
    // Hide received-photo KOT button
    const kotSection = m.querySelector('#galimg-kot-section');
    if (kotSection) kotSection.style.display = 'none';
    // Show gallery KOTs section (owner sees KOTs on their photos)
    const galKotsSection = m.querySelector('#galimg-gallery-kots-section');
    if (galKotsSection) {
      const galKots = typeof getGalleryKotsForItem === 'function'
        ? getGalleryKotsForItem(_galOpenId, itemId) : [];
      if (galKots.length) {
        const contacts = typeof getContacts === 'function' ? getContacts() : [];
        galKotsSection.style.display = '';
        galKotsSection.innerHTML = `<div style="font-size:12px;font-weight:600;color:var(--muted);margin-bottom:6px">✊ ${galKots.length} KOT${galKots.length > 1 ? 's' : ''}</div>` +
          galKots.map(k => {
            const c = contacts.find(x => x.pubKeyB64.startsWith(k.senderSlice16));
            const name = c?.journal?.displayName || c?.nickname || k.senderSlice16.slice(0, 8);
            return `<span style="font-size:12px;color:var(--text)">${k.fromAvatar ? esc(k.fromAvatar) + ' ' : ''}${esc(name)}</span>`;
          }).join(', ');
      } else {
        galKotsSection.style.display = 'none';
      }
    }
    // Show buddy notes section
    if (buddyNotesSection) {
      const bn = getGalleryBuddyNotes();
      const itemNotes = bn[_galOpenId]?.[itemId] || [];
      if (itemNotes.length) {
        const contacts = typeof getContacts === 'function' ? getContacts() : [];
        buddyNotesSection.style.display = '';
        buddyNotesSection.innerHTML = `<div style="font-size:12px;font-weight:600;color:var(--muted);margin-bottom:6px">Comments</div>` +
          itemNotes.map(n => {
            const c = contacts.find(x => x.pubKeyB64.startsWith(n.senderSlice16));
            const name = c?.journal?.displayName || c?.nickname || n.senderSlice16.slice(0, 8);
            const when = n.sentAt ? (typeof timeAgo === 'function' ? timeAgo(n.sentAt) : new Date(n.sentAt).toLocaleDateString([], { month: 'short', day: 'numeric' })) : '';
            return `<div class="comment-card"><div class="comment-card-author">${esc(name)}${when ? `<span class="comment-ts">${esc(when)}</span>` : ''}</div><div class="comment-card-body">${esc(n.text)}</div></div>`;
          }).join('');
      } else {
        buddyNotesSection.style.display = 'none';
      }
    }
  } else {
    // Show owner's note as read-only caption
    noteEl.value    = ownerNote;
    noteEl.readOnly = true;
    noteEl.placeholder = 'No note from owner';
    if (noteLabel) noteLabel.innerHTML = 'Owner\'s note';
    m.querySelector('#galimg-note-key').value = privateNoteKey || '';
    m.querySelector('#galimg-note-save-btn').style.display = 'none';
    if (buddyNotesSection) buddyNotesSection.style.display = 'none';
    const galKotsSection = m.querySelector('#galimg-gallery-kots-section');
    if (galKotsSection) galKotsSection.style.display = 'none';
    // Show "send a comment to owner" section
    const [sl16, galId] = (senderGalKey || `${_galOpenSender}:${_galOpenGalId}`).split(':');
    const senderGalKeyFull = `${sl16}:${galId}`;
    m.querySelector('#galimg-sender-gal-key').value = senderGalKeyFull;
    if (sendNoteSection) {
      sendNoteSection.style.display = '';
      const sendNoteEl = sendNoteSection.querySelector('#galimg-send-note');
      // Pre-populate with previously sent comment
      const prevSent = typeof getGallerySentNote === 'function'
        ? getGallerySentNote(sl16, galId, itemId) : null;
      if (sendNoteEl) sendNoteEl.value = prevSent ? prevSent.text : '';
      // Update button text
      const sendBtn = sendNoteSection.querySelector('[data-action="sendGalleryPhotoNote"]');
      if (sendBtn) sendBtn.textContent = prevSent ? 'Edit comment' : 'Send comment';
      // Show label
      const sendLabel = sendNoteSection.querySelector('#galimg-send-note-label');
      if (sendLabel) sendLabel.textContent = 'Leave a comment';
      // KOT button for received photos
      const kotSection = document.getElementById('galimg-kot-section');
      if (kotSection) {
        const kotSent = typeof getSentGalleryKot === 'function'
          ? getSentGalleryKot(sl16, galId, itemId) : null;
        kotSection.style.display = '';
        kotSection.innerHTML = kotSent
          ? '<button class="btn btn-sm kot-btn kot-active" disabled>✊ KOT\'d</button>'
          : `<button class="btn btn-sm kot-btn" data-action="sendGalleryKot"
              data-a0="${esc(sl16)}" data-a1="${esc(galId)}" data-a2="${esc(itemId)}">✊ KOT</button>`;
      }
    }
  }
  openModal('galImgModal');
}

// Open a gallery item from outside the gallery view (e.g. a journal-entry
// thumbnail) without first navigating into the gallery folder. We have to
// prime `_galOpenId` because openGalleryImageModal('mine') looks up the
// gallery by that module-level state.
function openLinkedGalleryImage(galleryId, itemId) {
  if (!galleryId || !itemId) return;
  _galOpenId = galleryId;
  openGalleryImageModal(itemId, 'mine');
}

function saveGalleryItemNote() {
  const m = document.getElementById('galImgModal');
  if (!m) return;
  const isMine = m.querySelector('#galimg-is-mine').value === '1';
  const itemId = m.querySelector('#galimg-item-id').value;
  const note   = m.querySelector('#galimg-note').value;

  if (isMine) {
    // Owner's note lives in the gallery item — travels with the image to buddies
    const galleries = getGalleries();
    const gi = galleries.findIndex(x => x.id === _galOpenId);
    if (gi < 0) return;
    const ii = galleries[gi].items.findIndex(i => i.id === itemId);
    if (ii < 0) return;
    galleries[gi].items[ii].note = note.trim() || '';
    galleries[gi].items[ii].updatedAt = new Date().toISOString();
    galleries[gi].updatedAt = new Date().toISOString();
    saveGalleries(galleries);
    // Push updated manifest to connected buddies
    _galNotifyPermChange(galleries[gi]);
    if (typeof p2pRelayGalleryManifest === 'function') p2pRelayGalleryManifest(galleries[gi]);
  } else {
    // Recipient's private note — stored locally, never transmitted
    const noteKey = m.querySelector('#galimg-note-key').value;
    const notes   = getGalleryNotes();
    if (note.trim()) notes[noteKey] = note;
    else delete notes[noteKey];
    saveGalleryNotes(notes);
  }
  toast('Note saved');
}

// Recipient's private note — stored locally, never transmitted
function saveGalleryPrivateNote() {
  const m = document.getElementById('galImgModal');
  if (!m) return;
  const noteKey = m.querySelector('#galimg-note-key').value;
  if (!noteKey) return;
  const pvtNote = m.querySelector('#galimg-private-note');
  if (!pvtNote) return;
  const notes = getGalleryNotes();
  if (pvtNote.value.trim()) notes[noteKey] = pvtNote.value;
  else delete notes[noteKey];
  saveGalleryNotes(notes);
  toast('Private note saved');
}

function saveGalleryItemTitle() {
  const m = document.getElementById('galImgModal');
  if (!m) return;
  if (m.querySelector('#galimg-is-mine').value !== '1') return;
  const itemId  = m.querySelector('#galimg-item-id').value;
  const newTitle = m.querySelector('#galimg-title').value.trim().slice(0, 120);
  const galleries = getGalleries();
  const gi = galleries.findIndex(x => x.id === _galOpenId);
  if (gi < 0) return;
  const ii = galleries[gi].items.findIndex(i => i.id === itemId);
  if (ii < 0) return;
  galleries[gi].items[ii].title = newTitle;
  galleries[gi].items[ii].updatedAt = new Date().toISOString();
  galleries[gi].updatedAt = new Date().toISOString();
  saveGalleries(galleries);
  // Push updated manifest to connected buddies
  _galNotifyPermChange(galleries[gi]);
  if (typeof p2pRelayGalleryManifest === 'function') p2pRelayGalleryManifest(galleries[gi]);
  renderGallery();
  toast('Title saved');
}

// ── Create gallery modal ───────────────────────────────────────────────────────
function showCreateGalleryModal(galleryId) {
  // galleryId present → edit mode; absent → create mode
  const m = document.getElementById('galCreateModal');
  if (!m) return;
  // Only show buddies who have actually synced (have a journal), not stale codes or tombstones
  const contacts  = getContacts().filter(c => c.journal && !c.vanished && !c.pendingRemoval && !c.removedAt && !c.forgottenAt);
  const editing   = galleryId ? getGalleries().find(g => g.id === galleryId) : null;
  const perms     = editing?.permissions || { tiers: [], contacts: [] };

  m.querySelector('#gal-modal-title-hdr').textContent = editing ? 'Edit permissions' : 'New gallery';
  m.querySelector('#gal-title-input').value = editing?.title || '';
  m.querySelector('#gal-title-input').style.display = editing ? 'none' : '';
  m.querySelector('#gal-title-lbl').style.display   = editing ? 'none' : '';
  m.querySelector('#gal-edit-id').value = galleryId || '';

  const tierBox = m.querySelector('#gal-tier-checks');
  tierBox.innerHTML = `
    <label style="display:flex;align-items:center;gap:8px;cursor:pointer">
      <input type="checkbox" id="gal-tier-buddy" ${perms.tiers.includes('buddy') ? 'checked' : ''}>
      <span>All buddies</span>
    </label>
    <label style="display:flex;align-items:center;gap:8px;cursor:pointer">
      <input type="checkbox" id="gal-tier-trusted" ${perms.tiers.includes('trusted') ? 'checked' : ''}>
      <span>All trusted</span>
    </label>`;

  const contactBox = m.querySelector('#gal-contact-checks');
  if (!contacts.length) {
    contactBox.innerHTML = `<p style="color:var(--muted);font-size:12px">No contacts yet</p>`;
  } else {
    contactBox.innerHTML = contacts.map(c => {
      const name    = c.journal?.displayName || c.pubKeyB64?.slice(0,8) || '?';
      const checked = perms.contacts.includes(c.pubKeyB64) ? 'checked' : '';
      return `<label style="display:flex;align-items:center;gap:8px;cursor:pointer">
        <input type="checkbox" class="gal-contact-ck" data-pub="${esc(c.pubKeyB64)}" ${checked}>
        <span>${esc(name)}</span>
      </label>`;
    }).join('');
  }

  openModal('galCreateModal');
}

function editGalleryPermissions(galleryId) {
  showCreateGalleryModal(galleryId);
}

function saveNewGallery() {
  const m = document.getElementById('galCreateModal');
  if (!m) return;
  const editId = m.querySelector('#gal-edit-id').value;
  const title  = m.querySelector('#gal-title-input').value.trim().slice(0, 80);
  if (!editId && !title) { toast('Enter a title', true); return; }

  const tierBuddy   = m.querySelector('#gal-tier-buddy')?.checked;
  const tierTrusted = m.querySelector('#gal-tier-trusted')?.checked;
  const tiers = [];
  if (tierBuddy)   tiers.push('buddy');
  if (tierTrusted) tiers.push('trusted');

  const selectedContacts = [];
  m.querySelectorAll('.gal-contact-ck:checked').forEach(ck => {
    if (ck.dataset.pub) selectedContacts.push(ck.dataset.pub);
  });

  const galleries = getGalleries();
  if (editId) {
    const gi = galleries.findIndex(g => g.id === editId);
    if (gi >= 0) {
      // Capture who had access BEFORE the change so we can revoke offline contacts
      const prevGallery = Object.assign({}, galleries[gi]);
      const allContacts = getContacts().filter(c => c.journal && !c.vanished && !c.pendingRemoval && !c.removedAt && !c.forgottenAt);
      const prevAccessors = allContacts.filter(c => galleryContactCanAccess(prevGallery, c)).map(c => c.pubKeyB64);
      galleries[gi].permissions = { tiers, contacts: selectedContacts };
      galleries[gi].updatedAt = new Date().toISOString();
      saveGalleries(galleries);
      toast('Permissions updated');
      // Notify connected peers; queue revoke for offline peers who lost access
      _galNotifyPermChange(galleries[gi], prevAccessors);
      if (typeof p2pRelayGalleryManifest === 'function') p2pRelayGalleryManifest(galleries[gi]);
    }
  } else {
    const newGal = {
      id: Date.now().toString(36) + Math.random().toString(36).slice(2, 6),
      title,
      createdAt: new Date().toISOString(),
      permissions: { tiers, contacts: selectedContacts },
      items: [],
    };
    galleries.push(newGal);
    saveGalleries(galleries);
    toast('Gallery created');
    if (typeof p2pRelayGalleryManifest === 'function') p2pRelayGalleryManifest(newGal);
    _galOpenId = newGal.id;
    _galTab    = 'mine';
    _galView   = 'folder';
  }
  closeModal('galCreateModal');
  renderGallery();
}

// ── Add photo to gallery ───────────────────────────────────────────────────────
function addPhotoToGallery(galleryId) {
  const input = document.createElement('input');
  input.type   = 'file';
  input.accept = 'image/*';
  input.style.display = 'none';
  // Must be in the DOM before .click() — iOS Safari GCs detached inputs
  document.body.appendChild(input);
  input.onchange = async () => {
    document.body.removeChild(input);
    const file = input.files[0];
    if (!file) return;
    if (file.size > 10 * 1024 * 1024) { toast('Image too large (max 10 MB)', true); return; }
    try {
      const key = await storeBlob(file);
      const galleries = getGalleries();
      const gi = galleries.findIndex(g => g.id === galleryId);
      if (gi < 0) return;
      const now = new Date().toISOString();
      const item = {
        id: Date.now().toString(36) + Math.random().toString(36).slice(2, 6),
        imgKey: key,
        title: '',
        note: '',
        addedAt: now,
        updatedAt: now,
      };
      galleries[gi].items.push(item);
      galleries[gi].updatedAt = now;
      saveGalleries(galleries);
      // Warm imgCache for immediate display
      const blob = await idbGet(key);
      if (blob && typeof imgCache !== 'undefined') {
        if (imgCache[key]) URL.revokeObjectURL(imgCache[key]);
        imgCache[key] = URL.createObjectURL(blob);
      }
      renderGallery();
      toast('Photo added');
      // Relay manifest + photo to offline buddies via signal server
      if (typeof p2pRelayGalleryManifest === 'function') p2pRelayGalleryManifest(galleries[gi]);
      if (typeof p2pRelayGalleryPhoto === 'function') p2pRelayGalleryPhoto(galleries[gi], item.id, key);
    } catch(e) { toast('Failed to add photo', true); console.error(e); }
  };
  input.click();
}

// ── Edit (crop/rotate/zoom) a gallery item photo ─────────────────────────────
async function editGalleryPhoto(galleryId, itemId) {
  const galleries = getGalleries();
  const g = galleries.find(x => x.id === galleryId);
  const item = g?.items.find(i => i.id === itemId);
  if (!item) return;
  const blob = await idbGet(item.imgKey);
  if (!(blob instanceof Blob)) { toast('Image not available — try again after it loads', true); return; }
  closeModal('galImgModal');
  if (typeof _ptOpenGallery === 'function') _ptOpenGallery(blob, galleryId, itemId, item.imgKey);
}

// ── Send a note to a gallery photo owner (recipient → owner) ──────────────────
async function sendGalleryPhotoNote() {
  const m = document.getElementById('galImgModal');
  if (!m) return;
  const senderGalKey = m.querySelector('#galimg-sender-gal-key').value;
  const itemId = m.querySelector('#galimg-item-id').value;
  const noteEl = m.querySelector('#galimg-send-note');
  if (!noteEl || !senderGalKey || !itemId) return;
  const note = noteEl.value.trim().slice(0, 500);
  if (!note) { toast('Write a comment first', true); return; }

  const [senderSlice16, galleryId] = senderGalKey.split(':');
  const recv = getGalleryReceived();
  const ownerPubKey = recv[senderSlice16]?.[galleryId]?.senderPubKey;
  if (!ownerPubKey) { toast('Cannot find gallery owner', true); return; }

  const result = typeof p2pSendGalleryPhotoNote === 'function'
    ? await p2pSendGalleryPhotoNote(ownerPubKey, galleryId, itemId, note)
    : 'failed';

  if (result === 'sent') {
    if (typeof recordGallerySentNote === 'function')
      recordGallerySentNote(senderSlice16, galleryId, itemId, note);
    toast('Comment sent');
    // Keep text in field, update button to "Edit comment"
    const sendBtn = m.querySelector('[data-action="sendGalleryPhotoNote"]');
    if (sendBtn) sendBtn.textContent = 'Edit comment';
    const sendLabel = m.querySelector('#galimg-send-note-label');
    if (sendLabel) sendLabel.textContent = 'Your comment';
  } else if (result === 'queued') {
    if (typeof recordGallerySentNote === 'function')
      recordGallerySentNote(senderSlice16, galleryId, itemId, note);
    toast('Comment will be delivered when owner is online');
    const sendBtn = m.querySelector('[data-action="sendGalleryPhotoNote"]');
    if (sendBtn) sendBtn.textContent = 'Edit comment';
    const sendLabel = m.querySelector('#galimg-send-note-label');
    if (sendLabel) sendLabel.textContent = 'Your comment';
  } else {
    toast('Could not send — owner appears offline', true);
  }
}

// ── Receive a buddy note on a gallery photo (owner receives) ──────────────────
function p2pHandleGalleryPhotoNote(contactPubKeyB64, msg) {
  const galleryId = typeof msg.galleryId === 'string' ? msg.galleryId.slice(0, 64)  : null;
  const itemId    = typeof msg.itemId    === 'string' ? msg.itemId.slice(0, 64)     : null;
  const note      = typeof msg.note      === 'string' ? msg.note.trim().slice(0, 500) : '';
  if (!galleryId || !itemId || !note) return;

  // Validate sender has access to this gallery and the item exists
  const galleries = getGalleries();
  const gallery = galleries.find(g => g.id === galleryId);
  if (!gallery) return;
  const contact = typeof getContacts === 'function'
    ? getContacts().find(c => c.pubKeyB64 === contactPubKeyB64) : null;
  if (!contact || !galleryContactCanAccess(gallery, contact)) return;
  const item = gallery.items.find(i => i.id === itemId);
  if (!item) return;

  // Store the note (replace existing note from same sender for this item)
  const senderSlice16 = contactPubKeyB64.slice(0, 16);
  const notes = getGalleryBuddyNotes();
  if (!notes[galleryId]) notes[galleryId] = {};
  if (!notes[galleryId][itemId]) notes[galleryId][itemId] = [];
  const existing = notes[galleryId][itemId];
  const idx = existing.findIndex(n => n.senderSlice16 === senderSlice16);
  const entry = { senderSlice16, text: note, sentAt: new Date().toISOString() };
  if (idx >= 0) existing[idx] = entry; else existing.push(entry);
  saveGalleryBuddyNotes(notes);

  // Notification
  const buddyName = contact?.journal?.displayName || contact?.nickname || 'Buddy';
  const photoLabel = item.title ? `"${item.title}"` : 'your photo';
  const galTitle   = gallery.title || 'Gallery';
  if (typeof pushNotification === 'function') {
    pushNotification('gallery', `${buddyName} commented on ${photoLabel} in "${galTitle}"`,
      note.length > 80 ? note.slice(0, 80) + '…' : note, {
      tag: `gal_note_${galleryId}_${itemId}_${senderSlice16}`,
      nav: { tab: 'gallery' },
    });
  }

  // Re-render if currently viewing this gallery or its folder
  renderGallery();
}

// ── Move photo to another gallery ──────────────────────────────────────────────
function moveGalleryItem(itemId, fromGalleryId) {
  const galleries = getGalleries();
  const fromGi = galleries.findIndex(g => g.id === fromGalleryId);
  if (fromGi < 0) return;
  const itemIdx = galleries[fromGi].items.findIndex(i => i.id === itemId);
  if (itemIdx < 0) return;

  const others = galleries.filter(g => g.id !== fromGalleryId);
  if (!others.length) { toast('No other galleries to move to. Create one first.', true); return; }

  // Build a picker overlay
  const itemTitle = galleries[fromGi].items[itemIdx].title || 'Photo';
  const overlay = document.createElement('div');
  overlay.style.cssText = 'position:fixed;inset:0;z-index:500;background:rgba(0,0,0,.6);display:flex;align-items:center;justify-content:center;padding:1rem';
  overlay.innerHTML = `<div class="modal-box" style="max-width:400px">
    <div class="modal-hdr"><span>Move "${esc(itemTitle)}"</span><button id="_moveClose" style="margin-left:auto;background:none;border:none;cursor:pointer;font-size:18px;color:var(--muted);padding:4px">✕</button></div>
    <div class="modal-body" style="display:flex;flex-direction:column;gap:6px">
      ${others.map(g => `<button class="btn btn-ghost" style="text-align:left;padding:10px 14px;width:100%" data-move-to="${esc(g.id)}">
        🖼 ${esc(g.title)} <span style="font-family:var(--mono);font-size:11px;color:var(--muted2)">(${g.items.length} photos)</span>
      </button>`).join('')}
    </div>
  </div>`;

  overlay.addEventListener('click', (ev) => {
    if (ev.target === overlay || ev.target.id === '_moveClose') overlay.remove();
  });
  overlay.querySelectorAll('[data-move-to]').forEach(btn => {
    btn.addEventListener('click', () => {
      overlay.remove();
      _doMoveGalleryItem(itemId, fromGalleryId, btn.dataset.moveTo);
    });
  });
  document.body.appendChild(overlay);
}

function _doMoveGalleryItem(itemId, fromGalleryId, toGalleryId) {
  const galleries = getGalleries();
  const fromGi = galleries.findIndex(g => g.id === fromGalleryId);
  const toGi   = galleries.findIndex(g => g.id === toGalleryId);
  if (fromGi < 0 || toGi < 0) return;
  const itemIdx = galleries[fromGi].items.findIndex(i => i.id === itemId);
  if (itemIdx < 0) return;

  const now = new Date().toISOString();
  const fromAccessors = new Set(_galGetAccessors(galleries[fromGi]));
  const toAccessors   = new Set(_galGetAccessors(galleries[toGi]));

  // Extract item from source (splice, not filter — ensures it's removed)
  const [item] = galleries[fromGi].items.splice(itemIdx, 1);
  galleries[fromGi].updatedAt = now;

  // Add to destination
  item.updatedAt = now;
  galleries[toGi].items.push(item);
  galleries[toGi].updatedAt = now;
  saveGalleries(galleries);

  // Move buddy notes: galleryId → galleryId
  const bn = typeof getGalleryBuddyNotes === 'function' ? getGalleryBuddyNotes() : {};
  if (bn[fromGalleryId]?.[itemId]) {
    if (!bn[toGalleryId]) bn[toGalleryId] = {};
    bn[toGalleryId][itemId] = bn[fromGalleryId][itemId];
    delete bn[fromGalleryId][itemId];
    if (!Object.keys(bn[fromGalleryId]).length) delete bn[fromGalleryId];
    if (typeof saveGalleryBuddyNotes === 'function') saveGalleryBuddyNotes(bn);
  }

  // Move KOTs
  const kots = typeof getGalleryKots === 'function' ? getGalleryKots() : {};
  const oldKotKey = `${fromGalleryId}:${itemId}`;
  const newKotKey = `${toGalleryId}:${itemId}`;
  if (kots[oldKotKey]) { kots[newKotKey] = kots[oldKotKey]; delete kots[oldKotKey]; }
  if (typeof saveGalleryKots === 'function') saveGalleryKots(kots);

  // Move sent KOTs
  const kotsSent = typeof getGalleryKotsSent === 'function' ? getGalleryKotsSent() : {};
  if (kotsSent[oldKotKey]) { kotsSent[newKotKey] = kotsSent[oldKotKey]; delete kotsSent[oldKotKey]; }
  if (typeof saveGalleryKotsSent === 'function') saveGalleryKotsSent(kotsSent);

  // Revoke from buddies who lost access
  const lostAccess = [...fromAccessors].filter(pk => !toAccessors.has(pk));
  for (const pub of lostAccess) {
    const peer = typeof peerConns !== 'undefined' ? peerConns.get(pub) : null;
    if (peer?.dc?.readyState === 'open' && typeof _dcSend === 'function') {
      _dcSend(peer.dc, { type: 'gallery_revoke', galleryId: fromGalleryId, itemIds: [itemId] });
    }
    _galQueueRevoke(pub, fromGalleryId, [itemId]);
  }

  // Send updated manifests for both galleries
  if (typeof p2pRelayGalleryManifest === 'function') {
    p2pRelayGalleryManifest(galleries[fromGi]);
    p2pRelayGalleryManifest(galleries[toGi]);
  }
  // Relay photo to buddies who gained access
  const gainedAccess = [...toAccessors].filter(pk => !fromAccessors.has(pk));
  if (gainedAccess.length && typeof p2pRelayGalleryPhoto === 'function') {
    p2pRelayGalleryPhoto(galleries[toGi], itemId, item.imgKey);
  }

  // Notify connected peers with updated manifests
  _galNotifyPermChange(galleries[fromGi], [...fromAccessors]);
  _galNotifyPermChange(galleries[toGi]);

  renderGallery();
  toast(`Moved to "${galleries[toGi].title}"`);
}

// ── Remove gallery item ───────────────────────────────────────────────────────
function removeGalleryItemConfirm(itemId, galleryId) {
  confirm2('Remove photo?', 'Remove this photo from the gallery? Copies cached on buddy devices will be deleted when they next connect. Offline buddies may retain the photo until then.', async () => {
    const galleries = getGalleries();
    const gi = galleries.findIndex(g => g.id === galleryId);
    if (gi < 0) return;
    const item = galleries[gi].items.find(i => i.id === itemId);
    galleries[gi].items = galleries[gi].items.filter(i => i.id !== itemId);
    galleries[gi].updatedAt = new Date().toISOString();
    saveGalleries(galleries);
    // Track deletion so device sync doesn't resurrect it
    const _del = typeof getGalleryDeleted === 'function' ? getGalleryDeleted() : { galleries: [], items: [] };
    _del.items.push({ galleryId, itemId, ts: Date.now() });
    if (_del.items.length > 500) _del.items = _del.items.slice(-500);
    if (typeof saveGalleryDeleted === 'function') saveGalleryDeleted(_del);
    // Clean up notes (owner's caption + buddy notes)
    const notes = getGalleryNotes();
    delete notes['own_' + itemId];
    saveGalleryNotes(notes);
    const bn = getGalleryBuddyNotes();
    if (bn[galleryId]) { delete bn[galleryId][itemId]; saveGalleryBuddyNotes(bn); }
    // Revoke on connected peers; queue for offline peers who had access
    if (item) {
      const accessors = _galGetAccessors(galleries[gi]);
      _galRevokePeers(galleryId, [itemId], accessors);
    }
    // Re-send updated manifest so connected peers also clean up via manifest-as-truth
    if (typeof p2pRelayGalleryManifest === 'function') p2pRelayGalleryManifest(galleries[gi]);
    if (typeof p2pSendGalleryManifests === 'function') {
      // Also send via DC to all connected peers with access
      const contacts = getContacts();
      if (typeof peerConns !== 'undefined') {
        for (const [pub, peer] of peerConns) {
          if (!peer.dc || peer.dc.readyState !== 'open') continue;
          const contact = contacts.find(c => c.pubKeyB64 === pub);
          if (contact && galleryContactCanAccess(galleries[gi], contact)) {
            _dcSend(peer.dc, { type: 'gallery_manifest', galleryId, title: galleries[gi].title, items: galleries[gi].items.map(i => ({ id: i.id, imgKey: i.imgKey, title: i.title || '', note: i.note || '', updatedAt: i.updatedAt || i.addedAt })) });
          }
        }
      }
    }
    if (typeof syncAfterSave === 'function') syncAfterSave();
    renderGallery();
    toast('Photo removed');
  });
}

// ── Delete whole gallery ──────────────────────────────────────────────────────
function deleteGalleryConfirm(galleryId) {
  confirm2('Delete gallery?', 'Delete this gallery? Copies already cached on buddy devices will be deleted when they next connect. Offline devices may retain access until then.', async () => {
    const galleries = getGalleries();
    const gi = galleries.findIndex(g => g.id === galleryId);
    if (gi < 0) return;
    // Revoke on connected peers; queue for offline peers who had access
    const accessors = _galGetAccessors(galleries[gi]);
    _galRevokePeers(galleryId, null, accessors);
    // Clean up notes (owner captions + buddy notes)
    const notes = getGalleryNotes();
    (galleries[gi].items || []).forEach(i => delete notes['own_' + i.id]);
    saveGalleryNotes(notes);
    const bn = getGalleryBuddyNotes();
    delete bn[galleryId];
    saveGalleryBuddyNotes(bn);
    // Track deletion so device sync doesn't resurrect it
    const _delG = typeof getGalleryDeleted === 'function' ? getGalleryDeleted() : { galleries: [], items: [] };
    _delG.galleries.push({ galleryId, ts: Date.now() });
    if (_delG.galleries.length > 200) _delG.galleries = _delG.galleries.slice(-200);
    if (typeof saveGalleryDeleted === 'function') saveGalleryDeleted(_delG);
    galleries.splice(gi, 1);
    saveGalleries(galleries);
    if (typeof syncAfterSave === 'function') syncAfterSave();
    backToGalleryList();
    renderGallery();
    toast('Gallery deleted');
  });
}

// ── Leave received gallery ─────────────────────────────────────────────────────
function leaveReceivedGallery(senderSlice16, galleryId) {
  confirm2('Leave gallery?', 'Remove this gallery and delete all cached images from your device?', async () => {
    // Delete IDB blobs
    try {
      const allKeys = await idbGetAllKeys();
      const prefix  = `gal_${senderSlice16}_${galleryId}_`;
      await Promise.all(allKeys.filter(k => k.startsWith(prefix)).map(k => idbDel(k)));
      // Revoke from imgCache
      if (typeof imgCache !== 'undefined') {
        for (const k of Object.keys(imgCache)) {
          if (k.startsWith(prefix)) { URL.revokeObjectURL(imgCache[k]); delete imgCache[k]; }
        }
      }
    } catch(e) { console.warn('[gallery] leave IDB error:', e); }
    // Remove from received store and mark as left
    const recv = getGalleryReceived();
    const galData = recv[senderSlice16]?.[galleryId];
    if (recv[senderSlice16]) {
      delete recv[senderSlice16][galleryId];
      if (!Object.keys(recv[senderSlice16]).length) delete recv[senderSlice16];
      saveGalleryReceived(recv);
    }
    // Record in left list so future manifests don't auto-re-add
    const left = getGalleryLeft();
    if (!left[senderSlice16]) left[senderSlice16] = {};
    left[senderSlice16][galleryId] = {
      title: galData?.title || 'Gallery',
      senderPubKey: galData?.senderPubKey || null,
      leftAt: new Date().toISOString(),
    };
    saveGalleryLeft(left);
    // Clean up notes
    const notes = getGalleryNotes();
    let changed = false;
    for (const k of Object.keys(notes)) {
      if (k.startsWith(`gal_${senderSlice16}_${galleryId}_`)) { delete notes[k]; changed = true; }
    }
    if (changed) saveGalleryNotes(notes);
    backToGalleryList();
    renderGallery();
    toast('Gallery removed');
  });
}

// ── Rejoin a left gallery ──────────────────────────────────────────────────────
function rejoinGallery(senderSlice16, galleryId) {
  const left = getGalleryLeft();
  if (left[senderSlice16]) {
    delete left[senderSlice16][galleryId];
    if (!Object.keys(left[senderSlice16]).length) delete left[senderSlice16];
    saveGalleryLeft(left);
  }
  renderGallery();
  toast('Gallery re-enabled — it will sync next time your buddy is online');
}

// ── Navigation ────────────────────────────────────────────────────────────────
function openGalleryFolder(galleryId, side) {
  _galTab    = side === 'received' ? 'received' : 'mine';
  _galView   = 'folder';
  _galOpenId = galleryId;
  if (typeof tab === 'function') tab('gallery');
  else renderGallery();
}

function openReceivedGallery(senderSlice16, galleryId) {
  _galTab        = 'received';
  _galView       = 'folder';
  _galOpenSender = senderSlice16;
  _galOpenGalId  = galleryId;
  if (typeof tab === 'function') tab('gallery');
  else renderGallery();
}

// ── Bookmark a photo ──────────────────────────────────────────────────────────

// Toggle bookmark on own journal entry or own gallery photo
function toggleBookmark(bmKey) {
  const bookmarks = typeof getBookmarks === 'function' ? getBookmarks() : {};
  if (bookmarks[bmKey]) {
    delete bookmarks[bmKey];
    toast('Bookmark removed');
  } else {
    bookmarks[bmKey] = { bookmarkedAt: new Date().toISOString() };
    toast('Bookmarked 🔖');
  }
  if (typeof saveBookmarks === 'function') saveBookmarks(bookmarks);
  if (typeof syncAfterSave === 'function') syncAfterSave();
  if (bmKey.startsWith('journal:')) {
    // If bookmarking a buddy's journal entry, notify them
    if (bookmarks[bmKey] && typeof _bmNotifyJournalOwner === 'function') _bmNotifyJournalOwner(bmKey);
    if (typeof selectEntry === 'function') selectEntry(bmKey.split(':')[1]);
  } else if (bmKey.startsWith('comparison:')) {
    // Re-render saved comparisons list
    if (typeof _renderSavedComparisons === 'function') _renderSavedComparisons();
  } else {
    closeModal('galImgModal');
    renderGallery();
  }
}

// Bookmark a received gallery photo
async function bookmarkReceivedPhoto(senderSlice16, galleryId, itemId, notifyOwner) {
  const bookmarks = typeof getBookmarks === 'function' ? getBookmarks() : {};
  const bmKey = `recv:${senderSlice16}:${galleryId}:${itemId}`;
  if (bookmarks[bmKey]) return;
  bookmarks[bmKey] = { bookmarkedAt: new Date().toISOString() };
  if (typeof saveBookmarks === 'function') saveBookmarks(bookmarks);
  if (typeof syncAfterSave === 'function') syncAfterSave();

  // Notify owner if mutually trusted
  if (notifyOwner === '1' || notifyOwner === true) {
    const recv = typeof getGalleryReceived === 'function' ? getGalleryReceived() : {};
    const ownerPub = recv[senderSlice16]?.[galleryId]?.senderPubKey;
    if (ownerPub && typeof p2pSendGalleryBookmark === 'function') {
      await p2pSendGalleryBookmark(ownerPub, galleryId, itemId);
    }
  }

  toast('Bookmarked 🔖');
  closeModal('galImgModal');
  renderGallery();
}

// Notify journal entry owner when we bookmark their entry
function _bmNotifyJournalOwner(bmKey) {
  const entryId = bmKey.split(':')[1];
  if (!entryId) return;
  // Find which buddy owns this entry
  const contacts = typeof getContacts === 'function' ? getContacts() : [];
  for (const c of contacts) {
    if (!c.journal?.entries || c.removedAt || c.forgottenAt) continue;
    if (!c.trusted || !c.senderTrusts) continue;
    const match = c.journal.entries.find(e => e.id === entryId);
    if (match) {
      if (typeof p2pSendJournalBookmark === 'function') p2pSendJournalBookmark(c.pubKeyB64, entryId);
      return;
    }
  }
}

// Handle incoming journal bookmark notification from a trusted buddy
function p2pHandleJournalBookmark(contactPubKeyB64, msg) {
  const entryId = typeof msg.entryId === 'string' ? msg.entryId.slice(0, 64) : null;
  if (!entryId) return;
  const contacts = typeof getContacts === 'function' ? getContacts() : [];
  const contact = contacts.find(c => c.pubKeyB64 === contactPubKeyB64);
  if (!contact?.trusted || !contact?.senderTrusts) return;
  // Verify we own this entry
  const entries = typeof getEntries === 'function' ? getEntries() : [];
  if (!entries.some(e => e.id === entryId)) return;

  const senderSlice16 = contactPubKeyB64.slice(0, 16);
  const bmRcvd = typeof getBookmarksReceived === 'function' ? getBookmarksReceived() : {};
  const bmKey = `journal:${entryId}`;
  if (!bmRcvd[bmKey]) bmRcvd[bmKey] = [];
  if (bmRcvd[bmKey].some(s => s.senderSlice16 === senderSlice16)) return;
  bmRcvd[bmKey].push({
    senderSlice16,
    senderName: contact.journal?.displayName || contact.nickname || 'Buddy',
    bookmarkedAt: new Date().toISOString()
  });
  if (typeof saveBookmarksReceived === 'function') saveBookmarksReceived(bmRcvd);
  if (typeof syncAfterSave === 'function') syncAfterSave();
  const buddyName = contact.journal?.displayName || contact.nickname || 'Buddy';
  if (typeof pushNotification === 'function') {
    pushNotification('gallery', `${buddyName} 🔖 bookmarked your journal entry`, '', {
      tag: `journal_bm_${entryId}_${senderSlice16}`,
      nav: { tab: 'journal' },
    });
  }
}

// Handle incoming bookmark notification from a trusted buddy
function p2pHandleGalleryBookmark(contactPubKeyB64, msg) {
  const galleryId = typeof msg.galleryId === 'string' ? msg.galleryId.slice(0, 64) : null;
  const itemId    = typeof msg.itemId    === 'string' ? msg.itemId.slice(0, 64)    : null;
  if (!galleryId || !itemId) return;

  const contacts = typeof getContacts === 'function' ? getContacts() : [];
  const contact = contacts.find(c => c.pubKeyB64 === contactPubKeyB64);
  if (!contact?.trusted || !contact?.senderTrusts) return;
  const galleries = typeof getGalleries === 'function' ? getGalleries() : [];
  const gallery = galleries.find(g => g.id === galleryId);
  if (!gallery || !galleryContactCanAccess(gallery, contact)) return;

  const senderSlice16 = contactPubKeyB64.slice(0, 16);
  const bmRcvd = typeof getBookmarksReceived === 'function' ? getBookmarksReceived() : {};
  const bmKey = `${galleryId}:${itemId}`;
  if (!bmRcvd[bmKey]) bmRcvd[bmKey] = [];
  if (bmRcvd[bmKey].some(s => s.senderSlice16 === senderSlice16)) return;
  bmRcvd[bmKey].push({
    senderSlice16,
    senderName: contact.journal?.displayName || contact.nickname || 'Buddy',
    bookmarkedAt: new Date().toISOString()
  });
  if (typeof saveBookmarksReceived === 'function') saveBookmarksReceived(bmRcvd);
  if (typeof syncAfterSave === 'function') syncAfterSave();

  const buddyName = contact.journal?.displayName || contact.nickname || 'Buddy';
  if (typeof pushNotification === 'function') {
    pushNotification('gallery', `${buddyName} 🔖 bookmarked your photo`, '', {
      tag: `gal_bm_${galleryId}_${itemId}_${senderSlice16}`,
      nav: { tab: 'gallery' },
    });
  }
  renderGallery();
}

function renameGallery(galleryId) {
  const galleries = getGalleries();
  const g = galleries.find(x => x.id === galleryId);
  if (!g) return;
  const newTitle = prompt('Rename gallery:', g.title);
  if (newTitle === null || !newTitle.trim()) return;
  g.title = newTitle.trim().slice(0, 80);
  g.updatedAt = new Date().toISOString();
  saveGalleries(galleries);
  renderGallery();
  toast('Gallery renamed');
  // Notify buddies of the title change
  if (typeof p2pRelayGalleryManifest === 'function') p2pRelayGalleryManifest(g);
}

function backToGalleryList() {
  _galView       = 'list';
  _galOpenId     = null;
  _galOpenSender = null;
  _galOpenGalId  = null;
  renderGallery();
}

function openBookmarksFolder() {
  _galTab  = 'bookmarks';
  _galView = 'folder';
  if (typeof tab === 'function') tab('gallery');
  else renderGallery();
}

function switchGalleryTab(tab) {
  _galTab  = tab;
  _galView = 'list';
  if (tab === 'mine') _galFilterSender = null;
  renderGallery();
}

function galClearFilter() {
  _galFilterSender = null;
  renderGallery();
}

// Open a buddy's received gallery from the contact list / journal panel
function openBuddyGallery(pubKeyB64) {
  const senderSlice16 = pubKeyB64.slice(0, 16);
  _galTab  = 'received';
  _galView = 'list';
  _galFilterSender = senderSlice16;
  if (typeof tab === 'function') tab('gallery');
  else renderGallery();
}

// ── P2P: send gallery manifests to a newly connected peer ─────────────────────
function p2pSendGalleryManifests(contactPubKeyB64) {
  const contact   = getContacts().find(c => c.pubKeyB64 === contactPubKeyB64);
  if (!contact) return;
  const galleries = getGalleries();
  const accessible = galleries.filter(g => galleryContactCanAccess(g, contact));
  if (!accessible.length) return;

  const peer = typeof peerConns !== 'undefined' ? peerConns.get(contactPubKeyB64) : null;
  if (!peer?.dc || peer.dc.readyState !== 'open') return;

  for (const g of accessible) {
    const items = g.items.map(i => ({ id: i.id, title: i.title, note: i.note || '', addedAt: i.addedAt, updatedAt: i.updatedAt || i.addedAt }));
    if (typeof _dcSend === 'function') {
      _dcSend(peer.dc, {
        type: 'gallery_manifest',
        galleryId: g.id,
        title:     g.title,
        items,
      });
    }
  }
}

// ── P2P: incoming manifest from peer (called from p2p.js) ─────────────────────
function p2pHandleGalleryManifest(contactPubKeyB64, msg) {
  const galleryId   = typeof msg.galleryId === 'string' ? msg.galleryId.slice(0, 64)  : null;
  const title       = typeof msg.title     === 'string' ? msg.title.slice(0, 80)      : 'Gallery';
  const items       = Array.isArray(msg.items) ? msg.items : [];
  if (!galleryId) return;

  const senderSlice16 = contactPubKeyB64.slice(0, 16);

  // Migrate reactions (KOTs, notes) when an item moves between galleries from the same sender
  function _migrateItemReactions(sl16, newGalId, itemId, recvData) {
    const senderGals = recvData[sl16] || {};
    let oldGalId = null;
    for (const [gid, gal] of Object.entries(senderGals)) {
      if (gid === newGalId) continue;
      if (gal.items?.[itemId]) { oldGalId = gid; break; }
    }
    if (!oldGalId) return; // not a move — genuinely new item

    // Migrate sent KOT
    const kotKey = sl16 + ':' + oldGalId + ':' + itemId;
    const newKotKey = sl16 + ':' + newGalId + ':' + itemId;
    const kotsSent = typeof getGalleryKotsSent === 'function' ? getGalleryKotsSent() : {};
    if (kotsSent[kotKey] && !kotsSent[newKotKey]) {
      kotsSent[newKotKey] = kotsSent[kotKey];
      delete kotsSent[kotKey];
      if (typeof saveGalleryKotsSent === 'function') saveGalleryKotsSent(kotsSent);
    }

    // Migrate sent note
    const notesSent = typeof getGallerySentNotes === 'function' ? getGallerySentNotes() : {};
    if (notesSent[kotKey] && !notesSent[newKotKey]) {
      notesSent[newKotKey] = notesSent[kotKey];
      delete notesSent[kotKey];
      if (typeof saveGallerySentNotes === 'function') saveGallerySentNotes(notesSent);
    }

    // Migrate cached IDB blob — copy old key to new key
    const oldMeta = senderGals[oldGalId]?.items?.[itemId];
    if (oldMeta?.idbKey) {
      const newIdbKey = `gal_${sl16}_${newGalId}_${itemId}`;
      if (typeof imgCache !== 'undefined' && imgCache[oldMeta.idbKey]) {
        imgCache[newIdbKey] = imgCache[oldMeta.idbKey];
      }
      // Copy blob in IDB (async, fire-and-forget)
      if (typeof idbGet === 'function' && typeof idbPut === 'function') {
        idbGet(oldMeta.idbKey).then(blob => { if (blob) idbPut(newIdbKey, blob); }).catch(() => {});
      }
    }
  }

  // If user left this gallery, don't auto-re-add it
  const left = typeof getGalleryLeft === 'function' ? getGalleryLeft() : {};
  if (left[senderSlice16]?.[galleryId]) {
    // Update the stored title so the "rejoin" UI stays current
    left[senderSlice16][galleryId].title = title;
    saveGalleryLeft(left);
    return;
  }

  const recv = getGalleryReceived();
  const isNewGallery = !recv[senderSlice16]?.[galleryId];
  if (!recv[senderSlice16])             recv[senderSlice16] = {};
  if (!recv[senderSlice16][galleryId])  recv[senderSlice16][galleryId] = { title, senderPubKey: contactPubKeyB64, items: {} };
  else { recv[senderSlice16][galleryId].title = title; recv[senderSlice16][galleryId].senderPubKey = contactPubKeyB64; }

  const existing = recv[senderSlice16][galleryId];
  const needFetch = [];

  for (const item of items) {
    const itemId = typeof item.id === 'string' ? item.id.slice(0, 64) : null;
    if (!itemId) continue;
    const idbKey = `gal_${senderSlice16}_${galleryId}_${itemId}`;
    const remoteUpdatedAt = item.updatedAt || item.addedAt || '';
    if (!existing.items[itemId]) {
      existing.items[itemId] = {
        idbKey,
        title:      (typeof item.title === 'string' ? item.title : '').slice(0, 120),
        note:       (typeof item.note  === 'string' ? item.note  : '').slice(0, 2000),
        updatedAt:  remoteUpdatedAt,
        receivedAt: new Date().toISOString(),
      };
      needFetch.push({ itemId, idbKey });
      // Migrate reactions if this item moved from another gallery (same sender, same itemId)
      _migrateItemReactions(senderSlice16, galleryId, itemId, recv);
    } else {
      // Deduplication: only update if remote is newer
      const localUpdatedAt = existing.items[itemId].updatedAt || '';
      if (remoteUpdatedAt > localUpdatedAt) {
        if (item.title != null) existing.items[itemId].title = String(item.title).slice(0, 120);
        existing.items[itemId].note = (typeof item.note === 'string' ? item.note : '').slice(0, 2000);
        existing.items[itemId].updatedAt = remoteUpdatedAt;
      }
    }
  }

  // Remove items that are no longer in the manifest (revoked/moved/deleted by owner)
  const manifestItemIds = new Set(items.map(i => typeof i.id === 'string' ? i.id : null).filter(Boolean));
  const removedItems = [];
  for (const localItemId of Object.keys(existing.items)) {
    if (!manifestItemIds.has(localItemId)) {
      const meta = existing.items[localItemId];
      if (meta?.idbKey) removedItems.push(meta.idbKey);
      delete existing.items[localItemId];
    }
  }
  // Delete IDB blobs for removed items
  if (removedItems.length) {
    Promise.all(removedItems.map(k => {
      if (typeof imgCache !== 'undefined' && imgCache[k]) { URL.revokeObjectURL(imgCache[k]); delete imgCache[k]; }
      return typeof idbDel === 'function' ? idbDel(k) : Promise.resolve();
    })).catch(e => console.warn('[gallery] manifest cleanup error:', e));
  }

  saveGalleryReceived(recv);

  // Queue image requests — drain one at a time per buddy (20s timeout, 3 retries)
  if (needFetch.length) {
    _galEnqueueImgRequests(contactPubKeyB64, galleryId, needFetch);
  }

  // Notify for new galleries or new photos added to existing galleries
  if (isNewGallery || needFetch.length) {
    if (needFetch.length === 0) {
      // New gallery with no images to fetch — notify immediately
      _galFireGalleryNotif(contactPubKeyB64, galleryId, title, items.length);
    } else {
      // Track how many images we're waiting on before firing the notification
      _galPendingNotif.set(`${contactPubKeyB64}:${galleryId}`, {
        title, total: needFetch.length, received: 0,
        isUpdate: !isNewGallery,
      });
    }
    // Refresh dashboard so gallery button appears on buddy's card
    if (typeof renderDash === 'function') renderDash();
  }

  // Always re-render gallery page (no-op if not currently visible)
  renderGallery();
}

// ── P2P: gallery image received and assembled (called from p2p.js) ────────────
function p2pGalleryImageReceived(contactPubKeyB64, galleryId, itemId, idbKey) {
  // Update received store with the idbKey (may already have metadata from manifest)
  const senderSlice16 = contactPubKeyB64.slice(0, 16);
  const recv = getGalleryReceived();
  if (!recv[senderSlice16])            recv[senderSlice16] = {};
  if (!recv[senderSlice16][galleryId]) recv[senderSlice16][galleryId] = { title: 'Gallery', senderPubKey: contactPubKeyB64, items: {} };
  if (!recv[senderSlice16][galleryId].items[itemId]) {
    recv[senderSlice16][galleryId].items[itemId] = { idbKey, title: '', receivedAt: new Date().toISOString() };
  } else {
    recv[senderSlice16][galleryId].items[itemId].idbKey = idbKey;
  }
  saveGalleryReceived(recv);
  // Mark inflight request as complete so queue drains next item
  _galImgRequestComplete(contactPubKeyB64, itemId);

  // Check if this was the last image for a newly-arrived gallery
  const pendingKey = `${contactPubKeyB64}:${galleryId}`;
  const pending = _galPendingNotif.get(pendingKey);
  if (pending) {
    pending.received++;
    if (pending.received >= pending.total) {
      _galPendingNotif.delete(pendingKey);
      _galFireGalleryNotif(contactPubKeyB64, galleryId, pending.title, pending.total, pending.isUpdate);
    }
  }

  // Re-render gallery page
  renderGallery();
  // Trigger canvas re-render for new image
  if (typeof rerenderPhotoCanvases === 'function') rerenderPhotoCanvases(idbKey);
}

// ── P2P: revoke (called from p2p.js when gallery_revoke received) ─────────────
async function p2pHandleGalleryRevoke(contactPubKeyB64, galleryId, itemIds) {
  // itemIds null = whole gallery; array = specific items
  const senderSlice16 = contactPubKeyB64.slice(0, 16);
  const recv = getGalleryReceived();
  const galData = recv[senderSlice16]?.[galleryId];
  if (!galData) return;

  const toDelete = itemIds === null
    ? Object.values(galData.items || {}).map(m => m.idbKey)
    : (itemIds || []).map(id => galData.items[id]?.idbKey).filter(Boolean);

  // Delete IDB blobs
  try {
    await Promise.all(toDelete.map(k => idbDel(k)));
    if (typeof imgCache !== 'undefined') {
      for (const k of toDelete) {
        if (imgCache[k]) { URL.revokeObjectURL(imgCache[k]); delete imgCache[k]; }
      }
    }
  } catch(e) { console.warn('[gallery] revoke IDB error:', e); }

  if (itemIds === null) {
    // Remove whole gallery
    delete recv[senderSlice16][galleryId];
    if (!Object.keys(recv[senderSlice16]).length) delete recv[senderSlice16];
  } else {
    // Remove specific items
    for (const id of itemIds) delete galData.items[id];
    recv[senderSlice16][galleryId] = galData;
  }
  saveGalleryReceived(recv);

  // Navigate away if looking at revoked gallery
  if (_galView === 'folder' && _galTab === 'received'
      && _galOpenSender === senderSlice16 && _galOpenGalId === galleryId) {
    if (itemIds === null) backToGalleryList();
    else renderGallery();
  }
  if (_galView === 'list' && _galTab === 'received') renderGallery();
}

// ── Internal: get all active contacts who can access a gallery ────────────────
function _galGetAccessors(gallery) {
  return getContacts()
    .filter(c => c.journal && !c.vanished && !c.pendingRemoval && !c.removedAt && !c.forgottenAt && galleryContactCanAccess(gallery, c))
    .map(c => c.pubKeyB64);
}

// ── Internal: queue a revoke for an offline peer ──────────────────────────────
// Coalesces: if a whole-gallery revoke already queued, skip item revokes.
// If new whole-gallery revoke, drop existing item revokes for that gallery.
function _galQueueRevoke(pubKeyB64, galleryId, itemIds) {
  const q = getGalleryRevokeQueue();
  if (!q[pubKeyB64]) q[pubKeyB64] = [];
  const existing = q[pubKeyB64];
  if (itemIds === null) {
    // Whole-gallery revoke: remove any prior item revokes for same gallery
    q[pubKeyB64] = existing.filter(e => !(e.galleryId === galleryId));
    q[pubKeyB64].push({ galleryId, itemIds: null, ts: Date.now() });
  } else {
    // Item revoke: skip if whole-gallery revoke already queued
    if (existing.some(e => e.galleryId === galleryId && e.itemIds === null)) return;
    // Merge with existing item revoke entry for same gallery if present
    const idx = existing.findIndex(e => e.galleryId === galleryId && e.itemIds !== null);
    if (idx >= 0) {
      const merged = new Set([...existing[idx].itemIds, ...itemIds]);
      existing[idx] = { galleryId, itemIds: [...merged], ts: Date.now() };
    } else {
      q[pubKeyB64].push({ galleryId, itemIds: [...itemIds], ts: Date.now() });
    }
  }
  saveGalleryRevokeQueue(q);
}

// ── Public: flush queued revokes for a peer on reconnect ─────────────────────
function galFlushRevokeQueue(pubKeyB64) {
  const q = getGalleryRevokeQueue();
  const pending = q[pubKeyB64];
  if (!pending || !pending.length) return;
  const peer = typeof peerConns !== 'undefined' ? peerConns.get(pubKeyB64) : null;
  if (!peer?.dc || peer.dc.readyState !== 'open') return;
  for (const { galleryId, itemIds } of pending) {
    if (typeof _dcSend === 'function') {
      _dcSend(peer.dc, { type: 'gallery_revoke', galleryId, itemIds });
    }
  }
  delete q[pubKeyB64];
  saveGalleryRevokeQueue(q);
}

// ── Internal: notify peers after permission change ────────────────────────────
// prevAccessors: array of pubKeyB64 who had access BEFORE the change
function _galNotifyPermChange(gallery, prevAccessors) {
  const contacts = getContacts();
  const prevSet = new Set(prevAccessors || []);

  // Handle currently-connected peers
  if (typeof peerConns !== 'undefined') {
    for (const [pubKeyB64, peer] of peerConns) {
      const connected = peer.dc && peer.dc.readyState === 'open';
      const contact = contacts.find(c => c.pubKeyB64 === pubKeyB64);
      if (!contact) continue;
      const nowHasAccess = galleryContactCanAccess(gallery, contact);
      if (connected) {
        if (nowHasAccess) {
          const items = gallery.items.map(i => ({ id: i.id, title: i.title, note: i.note || '', addedAt: i.addedAt, updatedAt: i.updatedAt || i.addedAt }));
          if (typeof _dcSend === 'function') {
            _dcSend(peer.dc, { type: 'gallery_manifest', galleryId: gallery.id, title: gallery.title, items });
          }
        } else if (prevSet.has(pubKeyB64)) {
          // Had access, now lost it — revoke via DC + queue as backup (DC may silently fail)
          if (typeof _dcSend === 'function') {
            _dcSend(peer.dc, { type: 'gallery_revoke', galleryId: gallery.id, itemIds: null });
          }
          _galQueueRevoke(pubKeyB64, gallery.id, null);
        }
      } else if (prevSet.has(pubKeyB64) && !nowHasAccess) {
        // Offline peer who lost access — queue the revoke
        _galQueueRevoke(pubKeyB64, gallery.id, null);
      }
    }
  }

  // Also check active contacts not in peerConns (definitely offline)
  const activeContacts = contacts.filter(c => c.journal && !c.vanished && !c.pendingRemoval && !c.removedAt && !c.forgottenAt);
  for (const contact of activeContacts) {
    const pub = contact.pubKeyB64;
    const nowHasAccess = galleryContactCanAccess(gallery, contact);
    if (prevSet.has(pub) && !nowHasAccess) {
      const peer = typeof peerConns !== 'undefined' ? peerConns.get(pub) : null;
      const connected = peer?.dc && peer.dc.readyState === 'open';
      if (!connected) _galQueueRevoke(pub, gallery.id, null);
    }
  }
}

function _galRevokePeers(galleryId, itemIds, accessors) {
  // accessors: array of pubKeyB64 who currently have/had access (used for offline queuing)
  const accessorSet = new Set(accessors || []);
  if (typeof peerConns !== 'undefined') {
    for (const [pubKeyB64, peer] of peerConns) {
      if (!accessorSet.size || accessorSet.has(pubKeyB64)) {
        if (peer.dc && peer.dc.readyState === 'open') {
          if (typeof _dcSend === 'function') {
            _dcSend(peer.dc, { type: 'gallery_revoke', galleryId, itemIds });
          }
        } else if (accessorSet.has(pubKeyB64)) {
          _galQueueRevoke(pubKeyB64, galleryId, itemIds);
        }
      }
    }
  }
  // Queue for active contacts not in peerConns at all
  for (const pub of accessorSet) {
    const peer = typeof peerConns !== 'undefined' ? peerConns.get(pub) : null;
    if (!peer) _galQueueRevoke(pub, galleryId, itemIds);
  }
}

// ── Gallery image request queue ─────────────────────────────────────────────
// One concurrent request per buddy. 20s timeout. 3 retries per item. Then skip.
// ── New-gallery notification tracker ─────────────────────────────────────────
// Maps 'pubKeyB64:galleryId' → { title, buddyName, total, received }
// Tracks galleries that arrived for the first time and are still downloading.
const _galPendingNotif = new Map();

function _galFireGalleryNotif(pubKeyB64, galleryId, title, photoCount, isUpdate) {
  const contact = typeof getContacts === 'function'
    ? getContacts().find(c => c.pubKeyB64 === pubKeyB64) : null;
  const buddyName = contact?.journal?.displayName || contact?.nickname || 'Buddy';
  const photoStr  = photoCount === 1 ? '1 photo' : `${photoCount} photos`;
  let body;
  if (isUpdate) {
    body = `${buddyName} added ${photoStr} to "${title}"`;
  } else {
    body = photoCount > 0
      ? `${buddyName} shared "${title}" (${photoStr})`
      : `${buddyName} shared "${title}" with you`;
  }
  if (typeof pushNotification === 'function') {
    pushNotification('gallery', body, '', {
      tag: `gallery_${isUpdate ? 'update' : 'new'}_${pubKeyB64.slice(0,16)}_${galleryId}`,
      nav: { tab: 'gallery' },
    });
  }
}

const _galImgQueues = new Map(); // pubKeyB64 → { queue: [], inflight: null, timer: null }
const _GAL_IMG_TIMEOUT = 20000;
const _GAL_IMG_MAX_RETRIES = 3;

function _galEnqueueImgRequests(contactPubKeyB64, galleryId, items) {
  let state = _galImgQueues.get(contactPubKeyB64);
  if (!state) {
    state = { queue: [], inflight: null, timer: null };
    _galImgQueues.set(contactPubKeyB64, state);
  }
  for (const { itemId, idbKey } of items) {
    // Skip if already cached in RAM or already queued
    const inCache = typeof imgCache !== 'undefined' && imgCache[idbKey];
    const alreadyQueued = state.queue.some(q => q.itemId === itemId && q.galleryId === galleryId);
    if (!inCache && !alreadyQueued) {
      state.queue.push({ galleryId, itemId, idbKey, retries: 0 });
    }
  }
  _galDrainQueue(contactPubKeyB64);
}

// Scan ALL received galleries from a peer for missing photo blobs and enqueue requests.
// Called when a peer comes online — catches the backlog of photos never fetched.
async function _galRequestMissingFromPeer(pubKeyB64) {
  const sl16 = pubKeyB64.slice(0, 16);
  const recv = typeof getGalleryReceived === 'function' ? getGalleryReceived() : {};
  const byGal = recv[sl16];
  if (!byGal) {
    _galDrainQueue(pubKeyB64);
    return;
  }

  let existing;
  try {
    const allKeys = typeof idbGetAllKeys === 'function' ? await idbGetAllKeys() : [];
    existing = new Set(allKeys);
  } catch(e) { existing = new Set(); }

  const toFetch = [];
  for (const [galId, galData] of Object.entries(byGal)) {
    for (const [itemId, meta] of Object.entries(galData.items || {})) {
      if (!meta.idbKey) continue;
      if (existing.has(meta.idbKey)) continue;
      if (typeof imgCache !== 'undefined' && imgCache[meta.idbKey]) continue;
      toFetch.push({ galleryId: galId, itemId, idbKey: meta.idbKey });
    }
  }


  if (toFetch.length) {
    const byGalId = new Map();
    for (const f of toFetch) {
      if (!byGalId.has(f.galleryId)) byGalId.set(f.galleryId, []);
      byGalId.get(f.galleryId).push(f);
    }
    for (const [galId, items] of byGalId) {
      _galEnqueueImgRequests(pubKeyB64, galId, items);
    }
  }
  _galDrainQueue(pubKeyB64);
}

let _galDrainRetries = new Map(); // pubKeyB64 → retry count
function _galDrainQueue(contactPubKeyB64) {
  const state = _galImgQueues.get(contactPubKeyB64);
  if (!state || state.inflight || !state.queue.length) return;
  const peer = typeof peerConns !== 'undefined' ? peerConns.get(contactPubKeyB64) : null;
  if (!peer?.dc || peer.dc.readyState !== 'open') {
    // Peer not connected yet — retry a few times (covers relay setup timing)
    const retries = _galDrainRetries.get(contactPubKeyB64) || 0;
    if (retries < 5) {
      _galDrainRetries.set(contactPubKeyB64, retries + 1);
      setTimeout(() => _galDrainQueue(contactPubKeyB64), 3000);
    }
    return;
  }
  _galDrainRetries.delete(contactPubKeyB64);

  const item = state.queue.shift();
  state.inflight = item;
  if (typeof _dcSend === 'function') {
    _dcSend(peer.dc, { type: 'gallery_req_img', galleryId: item.galleryId, itemId: item.itemId });
  }
  // Timeout — retry or skip
  state.timer = setTimeout(() => {
    state.inflight = null;
    state.timer = null;
    item.retries++;
    if (item.retries < _GAL_IMG_MAX_RETRIES) {
      state.queue.unshift(item); // re-queue at front
    }
    _galDrainQueue(contactPubKeyB64);
  }, _GAL_IMG_TIMEOUT);
}

// Called when a gallery image is received — clear inflight and drain next
function _galImgRequestComplete(contactPubKeyB64, itemId) {
  const state = _galImgQueues.get(contactPubKeyB64);
  if (!state) return;
  if (state.inflight && state.inflight.itemId === itemId) {
    clearTimeout(state.timer);
    state.inflight = null;
    state.timer = null;
  }
  _galDrainQueue(contactPubKeyB64);
}

// ═══════════════════════════════════════════════
// CHECK-IN — mutual trusted buddy photo reveal
// ═══════════════════════════════════════════════

async function postCheckIn(blob) {
  if (!blob) return;
  const id = typeof uuid === 'function' ? uuid() : Date.now().toString(36) + Math.random().toString(36).slice(2, 8);
  const imgKey = `checkin_${id}`;
  // Compress to 600px
  blob = await new Promise((resolve) => {
    const url = URL.createObjectURL(blob);
    const img = new Image();
    img.onload = () => {
      URL.revokeObjectURL(url);
      const maxDim = 600, q = 0.8;
      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 process photo', true); return; }

  if (typeof idbPut === 'function') await idbPut(imgKey, blob);
  if (typeof imgCache !== 'undefined') imgCache[imgKey] = URL.createObjectURL(blob);

  const checkins = getCheckIns();
  checkins.unshift({ id, imgKey, createdAt: new Date().toISOString() });
  saveCheckIns(checkins);
  if (typeof syncAfterSave === 'function') syncAfterSave();

  _broadcastCheckIn(id, imgKey, blob);
  toast('Check-in posted! Waiting for buddies to check in too.');
  if (typeof renderActivity === 'function') renderActivity();
  if (typeof renderDash === 'function') renderDash();
}

async function _broadcastCheckIn(checkInId, imgKey, blob) {
  const contacts = typeof getContacts === 'function' ? getContacts() : [];
  for (const c of contacts) {
    if (c.removedAt || c.forgottenAt || !c.trusted || !c.senderTrusts) continue;
    const dc = typeof _getDC === 'function' ? _getDC(c.pubKeyB64) : null;
    if (!dc || dc.readyState !== 'open') continue;
    let wmBlob = blob;
    if (typeof _watermarkBlob === 'function') wmBlob = await _watermarkBlob(blob, c.pubKeyB64.slice(0, 20), false) || blob;
    const reader = new FileReader();
    const b64 = await new Promise(r => { reader.onload = () => r(reader.result); reader.onerror = () => r(null); reader.readAsDataURL(wmBlob); });
    if (!b64) continue;
    if (typeof _dcSend === 'function') _dcSend(dc, { type: 'checkin_notify', checkInId, photo: b64 });
  }
}

async function p2pHandleCheckIn(contactPubKeyB64, msg) {
  const contacts = typeof getContacts === 'function' ? getContacts() : [];
  const c = contacts.find(x => x.pubKeyB64 === contactPubKeyB64);
  if (!c?.trusted || !c?.senderTrusts) return;
  const checkInId = msg.checkInId;
  const photo = msg.photo;
  if (!checkInId || !photo) return;

  const sl16 = contactPubKeyB64.slice(0, 16);
  const idbKey = `checkin_${sl16}_${checkInId}`;

  try {
    let b64 = photo;
    if (b64.includes(',')) b64 = b64.split(',')[1];
    const binStr = atob(b64);
    const bytes = new Uint8Array(binStr.length);
    for (let i = 0; i < binStr.length; i++) bytes[i] = binStr.charCodeAt(i);
    const blob = new Blob([bytes], { type: 'image/jpeg' });
    if (typeof idbPut === 'function') await idbPut(idbKey, blob);
    if (typeof imgCache !== 'undefined') imgCache[idbKey] = URL.createObjectURL(blob);
  } catch(e) { console.warn('[checkin] photo store error:', e); return; }

  const rcvd = getCheckInsReceived();
  if (!rcvd[sl16]) rcvd[sl16] = [];
  if (rcvd[sl16].some(ci => ci.id === checkInId)) return;
  rcvd[sl16].push({ id: checkInId, imgKey: idbKey, createdAt: new Date().toISOString(), senderName: c.journal?.displayName || c.nickname || 'Buddy' });
  saveCheckInsReceived(rcvd);
  if (typeof syncAfterSave === 'function') syncAfterSave();

  const buddyName = c.journal?.displayName || c.nickname || 'Buddy';
  const ownCheckins = getCheckIns();
  const recentOwn = ownCheckins.find(ci => Date.now() - new Date(ci.createdAt).getTime() < 24 * 3600000);
  if (recentOwn) {
    // Mutual! Send our photo back
    const ownBlob = typeof idbGet === 'function' ? await idbGet(recentOwn.imgKey) : null;
    if (ownBlob) {
      let wmBlob = ownBlob;
      if (typeof _watermarkBlob === 'function') wmBlob = await _watermarkBlob(ownBlob, contactPubKeyB64.slice(0, 20), false) || ownBlob;
      const reader = new FileReader();
      const b64Reply = await new Promise(r => { reader.onload = () => r(reader.result); reader.onerror = () => r(null); reader.readAsDataURL(wmBlob); });
      const dc = typeof _getDC === 'function' ? _getDC(contactPubKeyB64) : null;
      if (dc && dc.readyState === 'open' && b64Reply && typeof _dcSend === 'function') {
        _dcSend(dc, { type: 'checkin_notify', checkInId: recentOwn.id, photo: b64Reply });
      }
    }
    if (typeof pushNotification === 'function') pushNotification('gallery', `${buddyName} checked in! Mutual reveal unlocked 🔓`, '', { nav: { tab: 'activity' } });
  } else {
    if (typeof pushNotification === 'function') pushNotification('gallery', `${buddyName} checked in! Post yours to see theirs 📸`, '', { nav: { tab: 'activity' } });
  }
  if (typeof renderActivity === 'function') renderActivity();
  if (typeof renderDash === 'function') renderDash();
}

// ═══════════════════════════════════════════════
// QUICK CHECK — scheduled mutual photo exchange with trusted buddies
// ═══════════════════════════════════════════════

const _QC_CADENCES = {
  daily:     { label: 'Daily',     ms: 24 * 3600000 },
  weekly:    { label: 'Weekly',    ms: 7 * 24 * 3600000 },
  monthly:   { label: 'Monthly',   ms: 30 * 24 * 3600000 },
  quarterly: { label: 'Quarterly', ms: 91 * 24 * 3600000 },
  yearly:    { label: 'Yearly',    ms: 365 * 24 * 3600000 },
};
const _QC_GRACE_MS = 5 * 3600000;

function qcPeriodStart(cadence, date) {
  const d = new Date(date || Date.now());
  if (cadence === 'daily')     return new Date(d.getFullYear(), d.getMonth(), d.getDate()).toISOString();
  if (cadence === 'weekly')    { const day = d.getDay() || 7; d.setDate(d.getDate() - day + 1); return new Date(d.getFullYear(), d.getMonth(), d.getDate()).toISOString(); }
  if (cadence === 'monthly')   return new Date(d.getFullYear(), d.getMonth(), 1).toISOString();
  if (cadence === 'quarterly') { const q = Math.floor(d.getMonth() / 3) * 3; return new Date(d.getFullYear(), q, 1).toISOString(); }
  if (cadence === 'yearly')    return new Date(d.getFullYear(), 0, 1).toISOString();
  return new Date().toISOString();
}

function qcPeriodEnd(cadence, periodStart) {
  const d = new Date(periodStart);
  if (cadence === 'daily')     d.setDate(d.getDate() + 1);
  else if (cadence === 'weekly')    d.setDate(d.getDate() + 7);
  else if (cadence === 'monthly')   d.setMonth(d.getMonth() + 1);
  else if (cadence === 'quarterly') d.setMonth(d.getMonth() + 3);
  else if (cadence === 'yearly')    d.setFullYear(d.getFullYear() + 1);
  return d.toISOString();
}

function qcDeadlineStatus(qc) {
  if (!qc.active || !qc.acceptedAt) return 'idle';
  if (qc.revealed) return 'revealed';
  const now = Date.now();
  const end = new Date(qcPeriodEnd(qc.cadence, qc.currentPeriodStart)).getTime();
  const start = new Date(qc.currentPeriodStart).getTime();
  const total = end - start;
  const elapsed = now - start;
  const pct = elapsed / total;
  if (now > end + _QC_GRACE_MS) return 'expired';
  if (now > end) return 'grace';
  if (qc.mySubmittedAt) return 'submitted';
  if (pct >= 0.9) return 'danger';
  if (pct >= 0.7) return 'warning';
  return 'idle';
}

function qcStatusColor(status) {
  if (status === 'revealed')  return '#10b981';
  if (status === 'submitted') return '#3b82f6';
  if (status === 'warning')   return '#f59e0b';
  if (status === 'danger' || status === 'grace') return '#ef4444';
  return '#6b7280';
}

function qcTimeLeft(qc) {
  if (!qc.active || !qc.acceptedAt) return '';
  const end = new Date(qcPeriodEnd(qc.cadence, qc.currentPeriodStart)).getTime();
  const ms = end - Date.now();
  if (ms <= 0) return 'overdue';
  const h = Math.floor(ms / 3600000);
  if (h >= 24) return Math.floor(h / 24) + 'd ' + (h % 24) + 'h';
  return h + 'h ' + Math.floor((ms % 3600000) / 60000) + 'm';
}

function qcPropose(pubKeyB64, description, cadence) {
  if (!pubKeyB64 || !description || !_QC_CADENCES[cadence]) return;
  const contacts = getContacts();
  const c = contacts.find(x => x.pubKeyB64 === pubKeyB64);
  if (!c?.trusted || !c?.senderTrusts) { toast('Quick Check requires mutual trust', true); return; }
  const id = 'qc_' + (typeof uuid === 'function' ? uuid() : Date.now().toString(36) + Math.random().toString(36).slice(2, 8));
  const qcs = getQuickChecks();
  qcs.push({ id, buddyPubKey: pubKeyB64, description: description.slice(0, 120), cadence, createdAt: new Date().toISOString(), acceptedAt: null, initiatedByMe: true, active: true, currentPeriodStart: null, mySubmittedAt: null, myImgKey: null, peerSubmittedAt: null, peerImgKey: null, revealed: false, spree: 0, bestSpree: 0, history: [] });
  saveQuickChecks(qcs);
  const msg = { type: 'qc_propose', qcId: id, description: description.slice(0, 120), cadence };
  const dc = typeof _getDC === 'function' ? _getDC(pubKeyB64) : null;
  if (dc && dc.readyState === 'open' && typeof _dcSend === 'function') _dcSend(dc, msg);
  else { const queue = getQuickCheckQueue(); if (!queue[pubKeyB64]) queue[pubKeyB64] = []; queue[pubKeyB64].push(msg); saveQuickCheckQueue(queue); }
  toast('Quick Check proposed!');
  if (typeof renderDash === 'function') renderDash();
}

function qcAccept(qcId) {
  const qcs = getQuickChecks();
  const qc = qcs.find(q => q.id === qcId);
  if (!qc || qc.acceptedAt) return;
  qc.acceptedAt = new Date().toISOString();
  qc.currentPeriodStart = qcPeriodStart(qc.cadence);
  saveQuickChecks(qcs);
  const msg = { type: 'qc_accept', qcId };
  const dc = typeof _getDC === 'function' ? _getDC(qc.buddyPubKey) : null;
  if (dc && dc.readyState === 'open' && typeof _dcSend === 'function') _dcSend(dc, msg);
  else { const queue = getQuickCheckQueue(); if (!queue[qc.buddyPubKey]) queue[qc.buddyPubKey] = []; queue[qc.buddyPubKey].push(msg); saveQuickCheckQueue(queue); }
  toast('Quick Check accepted!');
  if (typeof renderDash === 'function') renderDash();
}

function qcDecline(qcId) {
  const qcs = getQuickChecks();
  const idx = qcs.findIndex(q => q.id === qcId);
  if (idx < 0) return;
  const buddyPub = qcs[idx].buddyPubKey;
  qcs.splice(idx, 1);
  saveQuickChecks(qcs);
  const dc = typeof _getDC === 'function' ? _getDC(buddyPub) : null;
  if (dc && dc.readyState === 'open' && typeof _dcSend === 'function') _dcSend(dc, { type: 'qc_decline', qcId });
  toast('Quick Check declined');
  if (typeof renderDash === 'function') renderDash();
}

async function qcSubmitPhoto(qcId, rawBlob) {
  if (!rawBlob) return;
  const qcs = getQuickChecks();
  const qc = qcs.find(q => q.id === qcId);
  if (!qc?.active || !qc?.acceptedAt) { toast('Quick Check not active', true); return; }
  if (qc.mySubmittedAt) { toast('Already submitted this period', true); return; }
  let blob = await new Promise((resolve) => {
    const url = URL.createObjectURL(rawBlob);
    const img = new Image();
    img.onload = () => { URL.revokeObjectURL(url); const maxDim = 600, q = 0.75, scale = Math.min(1, maxDim / Math.max(img.naturalWidth || 1, img.naturalHeight || 1)), w = Math.round((img.naturalWidth || 1) * scale), h = Math.round((img.naturalHeight || 1) * scale), 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 process photo', true); return; }
  if (typeof _watermarkBlob === 'function') blob = await _watermarkBlob(blob, qc.buddyPubKey.slice(0, 20), false) || blob;
  const period = qc.currentPeriodStart;
  const imgKey = `qc_${qcId}_me_${(period || '').replace(/[^a-zA-Z0-9]/g, '')}`;
  if (typeof idbPut === 'function') await idbPut(imgKey, blob);
  if (typeof imgCache !== 'undefined') imgCache[imgKey] = URL.createObjectURL(blob);
  qc.mySubmittedAt = new Date().toISOString();
  qc.myImgKey = imgKey;
  if (qc.peerSubmittedAt) {
    qc.revealed = true;
    qc.spree = (qc.spree || 0) + 1;
    if (qc.spree > (qc.bestSpree || 0)) qc.bestSpree = qc.spree;
    const c = getContacts().find(x => x.pubKeyB64 === qc.buddyPubKey);
    if (typeof pushNotification === 'function') pushNotification('gallery', `Quick Check revealed with ${c?.journal?.displayName || c?.nickname || 'Buddy'}! 🔓 Spree: ${qc.spree}`, '', { nav: { tab: 'dashboard' } });
  }
  saveQuickChecks(qcs);
  // Convert blob to base64 for transmission
  const reader = new FileReader();
  const photoB64 = await new Promise(r => { reader.onload = () => r(reader.result); reader.onerror = () => r(null); reader.readAsDataURL(blob); });
  const dc = typeof _getDC === 'function' ? _getDC(qc.buddyPubKey) : null;
  if (dc && dc.readyState === 'open' && typeof _dcSend === 'function') {
    // Send metadata first (no photo) so receiver can set up, then chunked photo
    _dcSend(dc, { type: 'qc_submit', qcId, period, imgKey });
    // Chunk the base64 into 48KB pieces to avoid DC message size limits
    const CHUNK = 48000;
    const data = photoB64 || '';
    const total = Math.ceil(data.length / CHUNK) || 1;
    for (let i = 0; i < total; i++) {
      _dcSend(dc, { type: 'qc_photo_chunk', qcId, idx: i, total, data: data.slice(i * CHUNK, (i + 1) * CHUNK) });
    }
  } else {
    // Queue full message for offline delivery (drains on next DC connect)
    const queueMsg = { type: 'qc_submit', qcId, period, imgKey, photo: photoB64 };
    const queue = getQuickCheckQueue();
    if (!queue[qc.buddyPubKey]) queue[qc.buddyPubKey] = [];
    queue[qc.buddyPubKey].push(queueMsg);
    saveQuickCheckQueue(queue);
  }
  toast(qc.revealed ? 'Revealed! 🔓' : 'Submitted! Waiting for buddy…');
  if (typeof renderDash === 'function') renderDash();
}

function p2pHandleQcPropose(contactPubKeyB64, msg) {
  const c = getContacts().find(x => x.pubKeyB64 === contactPubKeyB64);
  if (!c?.trusted || !c?.senderTrusts) return;
  if (!msg.qcId || !msg.description || !_QC_CADENCES[msg.cadence]) return;
  const qcs = getQuickChecks();
  if (qcs.some(q => q.id === msg.qcId)) return;
  qcs.push({ id: msg.qcId, buddyPubKey: contactPubKeyB64, description: (msg.description || '').slice(0, 120), cadence: msg.cadence, createdAt: new Date().toISOString(), acceptedAt: null, initiatedByMe: false, active: true, currentPeriodStart: null, mySubmittedAt: null, myImgKey: null, peerSubmittedAt: null, peerImgKey: null, revealed: false, spree: 0, bestSpree: 0, history: [] });
  saveQuickChecks(qcs);
  const name = c.journal?.displayName || c.nickname || 'Buddy';
  if (typeof pushNotification === 'function') pushNotification('gallery', `${name} wants to Quick Check: "${msg.description}" (${_QC_CADENCES[msg.cadence].label})`, '', { nav: { tab: 'dashboard' } });
  if (typeof renderDash === 'function') renderDash();
}

function p2pHandleQcAccept(contactPubKeyB64, msg) {
  if (!msg.qcId) return;
  const qcs = getQuickChecks();
  const qc = qcs.find(q => q.id === msg.qcId && q.buddyPubKey === contactPubKeyB64);
  if (!qc || qc.acceptedAt) return;
  qc.acceptedAt = new Date().toISOString();
  qc.currentPeriodStart = qcPeriodStart(qc.cadence);
  saveQuickChecks(qcs);
  const c = getContacts().find(x => x.pubKeyB64 === contactPubKeyB64);
  toast(`${c?.journal?.displayName || c?.nickname || 'Buddy'} accepted your Quick Check!`);
  if (typeof renderDash === 'function') renderDash();
}

function p2pHandleQcDecline(contactPubKeyB64, msg) {
  if (!msg.qcId) return;
  const qcs = getQuickChecks();
  const idx = qcs.findIndex(q => q.id === msg.qcId && q.buddyPubKey === contactPubKeyB64);
  if (idx < 0) return;
  qcs.splice(idx, 1);
  saveQuickChecks(qcs);
  toast(`Quick Check declined`);
  if (typeof renderDash === 'function') renderDash();
}

// Buffer for chunked QC photo assembly
const _qcPhotoBuf = {}; // qcId → { chunks: [], total, period, contactPubKeyB64 }

function p2pHandleQcPhotoChunk(contactPubKeyB64, msg) {
  if (!msg.qcId || msg.idx == null || !msg.total || !msg.data) return;
  const key = msg.qcId + ':' + contactPubKeyB64;
  if (!_qcPhotoBuf[key]) _qcPhotoBuf[key] = { chunks: new Array(msg.total), received: 0, total: msg.total, contactPubKeyB64 };
  const buf = _qcPhotoBuf[key];
  if (buf.chunks[msg.idx]) return; // duplicate
  buf.chunks[msg.idx] = msg.data;
  buf.received++;
  if (buf.received >= buf.total) {
    const fullB64 = buf.chunks.join('');
    delete _qcPhotoBuf[key];
    _qcStorePhoto(contactPubKeyB64, msg.qcId, fullB64);
  }
}

async function _qcStorePhoto(contactPubKeyB64, qcId, photoB64) {
  const qcs = getQuickChecks();
  const qc = qcs.find(q => q.id === qcId && q.buddyPubKey === contactPubKeyB64 && q.active && q.acceptedAt);
  if (!qc || qc.peerSubmittedAt) return;
  const period = qc.currentPeriodStart || '';
  const peerImgKey = `qc_${qcId}_peer_${period.replace(/[^a-zA-Z0-9]/g, '')}`;
  try {
    let b64 = photoB64; if (b64.includes(',')) b64 = b64.split(',')[1];
    const binStr = atob(b64); const bytes = new Uint8Array(binStr.length);
    for (let i = 0; i < binStr.length; i++) bytes[i] = binStr.charCodeAt(i);
    const blob = new Blob([bytes], { type: 'image/jpeg' });
    if (typeof idbPut === 'function') await idbPut(peerImgKey, blob);
    if (typeof imgCache !== 'undefined') imgCache[peerImgKey] = URL.createObjectURL(blob);
  } catch(e) { console.warn('[qc] photo store error:', e); return; }
  qc.peerSubmittedAt = new Date().toISOString();
  qc.peerImgKey = peerImgKey;
  const c = getContacts().find(x => x.pubKeyB64 === contactPubKeyB64);
  const name = c?.journal?.displayName || c?.nickname || 'Buddy';
  if (qc.mySubmittedAt) {
    qc.revealed = true; qc.spree = (qc.spree || 0) + 1;
    if (qc.spree > (qc.bestSpree || 0)) qc.bestSpree = qc.spree;
    if (typeof pushNotification === 'function') pushNotification('gallery', `Quick Check revealed with ${name}! 🔓 Spree: ${qc.spree}`, '', { nav: { tab: 'dashboard' } });
  } else {
    if (typeof pushNotification === 'function') pushNotification('gallery', `${name} submitted their Quick Check! Your turn 📸`, '', { nav: { tab: 'dashboard' } });
  }
  saveQuickChecks(qcs);
  if (typeof renderDash === 'function') renderDash();
}

async function p2pHandleQcSubmit(contactPubKeyB64, msg) {
  if (!msg.qcId || !msg.period) return;
  // If photo is included inline (from queued offline delivery), store it directly
  if (msg.photo) {
    return _qcStorePhoto(contactPubKeyB64, msg.qcId, msg.photo);
  }
  // Otherwise, just record that submission happened — photo arrives via qc_photo_chunk
  const qcs = getQuickChecks();
  const qc = qcs.find(q => q.id === msg.qcId && q.buddyPubKey === contactPubKeyB64 && q.active && q.acceptedAt);
  if (!qc) return;
  // Mark period so chunk handler knows which QC this is for
  if (msg.period) qc._pendingPeerPeriod = msg.period;
  saveQuickChecks(qcs);
  // Photo will arrive via qc_photo_chunk messages — reveal happens in _qcStorePhoto
}

function p2pHandleQcCancel(contactPubKeyB64, msg) {
  if (!msg.qcId) return;
  const qcs = getQuickChecks();
  const qc = qcs.find(q => q.id === msg.qcId && q.buddyPubKey === contactPubKeyB64);
  if (!qc) return;
  qc.active = false;
  qc.cancelledAt = new Date().toISOString();
  saveQuickChecks(qcs);
  // Propagate cancellation to own paired devices
  if (typeof syncAfterSave === 'function') syncAfterSave();
  toast('A Quick Check was cancelled');
  if (typeof renderDash === 'function') renderDash();
}

function qcCancel(qcId) {
  const qcs = getQuickChecks();
  const qc = qcs.find(q => q.id === qcId);
  if (!qc) return;
  qc.active = false;
  qc.cancelledAt = new Date().toISOString();
  saveQuickChecks(qcs);
  // Send to buddy: DC if open, otherwise queue for next reconnect
  const cancelMsg = { type: 'qc_cancel', qcId };
  const dc = typeof _getDC === 'function' ? _getDC(qc.buddyPubKey) : null;
  if (dc && dc.readyState === 'open' && typeof _dcSend === 'function') {
    _dcSend(dc, cancelMsg);
  } else {
    const queue = getQuickCheckQueue();
    if (!queue[qc.buddyPubKey]) queue[qc.buddyPubKey] = [];
    queue[qc.buddyPubKey].push(cancelMsg);
    saveQuickCheckQueue(queue);
  }
  // Sync the cancel to own devices immediately
  if (typeof syncAfterSave === 'function') syncAfterSave();
  toast('Quick Check cancelled');
  if (typeof renderDash === 'function') renderDash();
}

function qcTickPeriods() {
  const qcs = getQuickChecks();
  let dirty = false;
  const now = Date.now();
  for (const qc of qcs) {
    if (!qc.active || !qc.acceptedAt || !qc.currentPeriodStart) continue;
    const end = new Date(qcPeriodEnd(qc.cadence, qc.currentPeriodStart)).getTime();
    if (now < end + _QC_GRACE_MS) continue;
    const success = !!qc.revealed;
    qc.history = qc.history || [];
    qc.history.push({ period: qc.currentPeriodStart, success, spreeAtEnd: qc.spree });
    if (qc.history.length > 200) qc.history = qc.history.slice(-200);
    if (!success) { if (qc.spree > (qc.bestSpree || 0)) qc.bestSpree = qc.spree; qc.spree = 0; }
    if (qc.myImgKey) { if (typeof idbDel === 'function') idbDel(qc.myImgKey); if (typeof imgCache !== 'undefined' && imgCache[qc.myImgKey]) { URL.revokeObjectURL(imgCache[qc.myImgKey]); delete imgCache[qc.myImgKey]; } }
    if (qc.peerImgKey) { if (typeof idbDel === 'function') idbDel(qc.peerImgKey); if (typeof imgCache !== 'undefined' && imgCache[qc.peerImgKey]) { URL.revokeObjectURL(imgCache[qc.peerImgKey]); delete imgCache[qc.peerImgKey]; } }
    qc.currentPeriodStart = qcPeriodStart(qc.cadence);
    qc.mySubmittedAt = null; qc.myImgKey = null; qc.peerSubmittedAt = null; qc.peerImgKey = null; qc.revealed = false;
    dirty = true;
  }
  if (dirty) saveQuickChecks(qcs);
}

setTimeout(qcTickPeriods, 5000);
setInterval(qcTickPeriods, 60000);

function qcDrainQueue(pubKeyB64) {
  const queue = getQuickCheckQueue();
  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];
  saveQuickCheckQueue(queue);
}
