'use strict';
// ═══════════════════════════════════════════════
// js/p2p.js -- Buddy sync via encrypted WS relay
//
// Both peers connect to forestorers-signal.onrender.com via WebSocket.
// The signal server matches buddies and relays their encrypted messages.
// All journal/image data is AES-256-GCM encrypted on-device before
// transmission -- the relay sees only opaque ciphertext it cannot decrypt.
// Encrypted queues are persisted to disk for offline delivery (auto-expire per TTL).
// Peers never exchange IP addresses.
//
// HANDSHAKE PROTOCOL:
//   Initiator (lower pubKey) sends {type:'hello'} first.
//   Receiver echoes {type:'hello'} back so both sides send their journals.
//   This symmetric exchange ensures both directions always sync.
// ═══════════════════════════════════════════════

const CHUNK_SIZE = 16384; // 16KB per DataChannel message

// Generate 16-byte random nonce as url-safe base64
function _randomNonce() {
  return toUrlSafeB64(b64FromBytes(crypto.getRandomValues(new Uint8Array(16))));
}

// Map of pubKeyB64 → { pc: null, dc: relayDc, relay: true }
const peerConns = new Map();

// ─── Message authentication (S1) ────────────────────────────────────────────
const _hmacKeys = new Map();         // pubKeyB64 → CryptoKey (cleared on disconnect)
const _recentNonces = new Map();     // pubKeyB64 → string[] (circular buffer, max 100)
const _MSG_MAX_AGE = 60000;          // 60s anti-replay window
const _peerProtoVersion = new Map(); // pubKeyB64 → number

async function _getHmacKey(pubKeyB64) {
  let key = _hmacKeys.get(pubKeyB64);
  if (key) return key;
  const identity = await getIdentity();
  if (!identity?.privKey) return null;
  key = await deriveHmacKey(identity.privKey, pubKeyB64);
  _hmacKeys.set(pubKeyB64, key);
  return key;
}

// Shared verification helper for control messages (trust_ack, settings_push)
// fields: object with the payload fields to verify (excluding type and sig)
// Returns true if message is valid, false if rejected
async function _verifyControlMsg(msg, fields, pubKeyB64) {
  const hmacKey = await _getHmacKey(pubKeyB64);
  const peerVersion = _peerProtoVersion.get(pubKeyB64) || 1;
  // Check sig
  if (msg.sig) {
    if (!hmacKey) return false;
    const valid = await hmacVerify(fields, msg.sig, hmacKey);
    if (!valid) {
      console.warn(`[p2p] HMAC verification failed for ${msg.type} from ${pubKeyB64.slice(0,8)}…`);
      toast('Message authentication failed -- possible tampering', true);
      return false;
    }
  } else if (peerVersion >= 2) {
    console.warn(`[p2p] missing sig on ${msg.type} from v${peerVersion} peer ${pubKeyB64.slice(0,8)}…`);
    return false;
  } else {
    console.warn(`[p2p] unsigned ${msg.type} from v1 peer ${pubKeyB64.slice(0,8)}… -- accepting`);
  }
  // Timestamp check
  if (msg.ts != null && Math.abs(Date.now() - msg.ts) > _MSG_MAX_AGE) {
    console.warn(`[p2p] ${msg.type} ts too old from ${pubKeyB64.slice(0,8)}…`);
    return false;
  }
  // Nonce replay check
  if (msg.nonce != null) {
    const nonces = _recentNonces.get(pubKeyB64) || [];
    if (nonces.includes(msg.nonce)) {
      console.warn(`[p2p] ${msg.type} nonce replay from ${pubKeyB64.slice(0,8)}…`);
      return false;
    }
    nonces.push(msg.nonce);
    if (nonces.length > 100) nonces.shift();
    _recentNonces.set(pubKeyB64, nonces);
  }
  return true;
}

// In-memory-only journals for peers who haven't granted offline copy permission.
// Cleared when peer disconnects.
const _ephemeralJournals = new Map();

// Track which buddies have synced at least once this session (for "profile synced" notification)
const _sessionSynced = new Set();

// idbKeys that are in RAM only (not persisted to IDB) -- evicted from imgCache on peer disconnect
const _sessionOnlyImgKeys = new Set();

// Track consecutive connection failures per peer -- used to surface a toast after repeated failures
const _connFailures = new Map();

// Track reconnect attempts per peer for exponential backoff
const _reconnectAttempts = new Map();

// Track peers in WS relay mode
const _wsRelayPeers = new Set();

// Relay replay protection -- nonce buffer per peer (FIFO, cap 200)
const _relayNonces = new Map();
const _RELAY_MAX_AGE = 120000; // 2 min replay window

// Track idle (curtain drawn) state per peer
const _peerIdle = new Map();

// Track live activity (tugging/retaining) per peer
const _peerActivity = new Map();

// Ephemeral buddy timer status -- never persisted, cleared on disconnect or curtain
// Map of pubKeyB64 → { type: 'tugging'|'retaining', startedAt: ms } | null
const _buddyTimerStates = new Map();

// Buddy idle state -- Set of pubKeyB64 currently flagged idle ("away").
// Populated via `buddy_idle_status` broadcasts from the signal server.
// UI uses this to render an orange orb instead of green.
const _buddyIdleStates = new Set();
function isBuddyIdle(pubKeyB64) { return _buddyIdleStates.has(pubKeyB64); }

// ── My own idle tracker ───────────────────────────────────────────────────────
// Idle = tab hidden for IDLE_HIDDEN_DELAY OR no user activity for IDLE_AFK_DELAY.
// On either trigger we send `presence_idle: true` so other clients render an
// orange orb. Any activity / visibility-restore flips back to active instantly.
let _localIdle = false;
let _lastActivityTs = Date.now();
let _idleHiddenTimer = null;
let _idleAfkTimer = null;
const IDLE_HIDDEN_DELAY = 120 * 1000; // 2 min tab hidden → idle
const IDLE_AFK_DELAY    = 300 * 1000; // 5 min no input while visible → idle
function _sendIdleState(idle) {
  if (_localIdle === idle) return;
  _localIdle = idle;
  if (_sigWs && _sigWs.readyState === WebSocket.OPEN && _presenceId) {
    _sigSend({ type: 'presence_idle', idle });
  }
}
function _onUserActivity() {
  _lastActivityTs = Date.now();
  if (_localIdle && document.visibilityState !== 'hidden') _sendIdleState(false);
  // Restart the AFK timer
  if (_idleAfkTimer) clearTimeout(_idleAfkTimer);
  _idleAfkTimer = setTimeout(() => {
    if (document.visibilityState !== 'hidden') _sendIdleState(true);
  }, IDLE_AFK_DELAY);
}
function _onVisibilityChange() {
  if (document.visibilityState === 'hidden') {
    // Tab just went hidden -- arm the hidden-delay timer
    if (_idleHiddenTimer) clearTimeout(_idleHiddenTimer);
    _idleHiddenTimer = setTimeout(() => { _sendIdleState(true); }, IDLE_HIDDEN_DELAY);
  } else {
    // Tab visible again -- cancel pending idle and snap to active
    if (_idleHiddenTimer) { clearTimeout(_idleHiddenTimer); _idleHiddenTimer = null; }
    _sendIdleState(false);
    _onUserActivity(); // also resets the AFK timer
  }
}
if (typeof document !== 'undefined') {
  document.addEventListener('visibilitychange', _onVisibilityChange);
  // Light-touch activity tracking: any keystroke, click, scroll, touch counts.
  // We don't track mousemove (too noisy) -- the AFK threshold of 5 min is loose
  // enough that mouse activity isn't required to stay marked active.
  ['keydown', 'mousedown', 'touchstart', 'wheel'].forEach(evt =>
    document.addEventListener(evt, _onUserActivity, { passive: true, capture: true }));
  // Prime the AFK timer
  _onUserActivity();
  // If the page loaded already hidden, arm the hidden-delay immediately
  if (document.visibilityState === 'hidden') _onVisibilityChange();
}

// Rate-limit incoming image requests -- max 20 per peer per minute
const _imgReqTimes = new Map();
function _checkImgRateLimit(pubKeyB64) {
  const now = Date.now();
  const times = (_imgReqTimes.get(pubKeyB64) || []).filter(t => now - t < 60000);
  if (times.length >= 60) return false; // 60 images/min (covers galleries + journal)
  times.push(now);
  _imgReqTimes.set(pubKeyB64, times);
  return true;
}

// Track image requests that were denied by the sender -- prevents re-requesting forever.
// Key format: "pubKeyB64:entryId:field" -- cleared on reconnect (permissions may change)
const _imgDenied = new Set();

// Track in-flight image requests for auto-retry on timeout
// Key: "pubKeyB64:entryId:field" → { requestedAt, retries }
const _imgRequests = new Map();
const _IMG_REQUEST_MAX_RETRIES = 5;
const _IMG_REQUEST_TIMEOUT = 20000; // 20s

function _clearImgDeniedForPeer(pubKeyB64) {
  const prefix = `${pubKeyB64}:`;
  for (const k of _imgDenied) { if (k.startsWith(prefix)) _imgDenied.delete(k); }
  for (const k of _imgRequests.keys()) { if (k.startsWith(prefix)) _imgRequests.delete(k); }
}

// Track peers we've already sent hello to this session -- prevents double-hello
// and enables the echo-hello pattern (receiver echoes hello so initiator also sends journal)
const _helloedPeers = new Set();

function _scheduleReconnect(contactPubKeyB64) {
  const attempts = (_reconnectAttempts.get(contactPubKeyB64) || 0) + 1;
  _reconnectAttempts.set(contactPubKeyB64, attempts);
  if (attempts > 6) {
    console.log(`[p2p] ${contactPubKeyB64.slice(0,8)}… giving up after ${attempts} attempts -- check network`);
    return;
  }
  // Exponential backoff: 5s, 10s, 20s, 40s, 80s, 160s (cap 3min), plus random jitter
  const delay = Math.min(5000 * Math.pow(2, attempts - 1), 180000) + Math.random() * 3000;
  const shortKey = contactPubKeyB64.slice(0, 8);
  console.log(`[p2p] scheduling reconnect to ${shortKey}… in ${Math.round(delay/1000)}s (attempt ${attempts}/6)`);
  // Lower-key side initiates (primary)
  setTimeout(() => {
    if (!isOnline(contactPubKeyB64) && getContacts().some(c => c.pubKeyB64 === contactPubKeyB64)) {
      if (_myPubKey && _myPubKey < contactPubKeyB64) {
        console.log(`[p2p] ${shortKey}… reconnect attempt ${attempts} (lower-key initiator)`);
        _autoConnQueue.push({ role: 'initiator', pubKey: contactPubKeyB64 });
        _runAutoConnQueue();
      }
    }
  }, delay);
  // Higher-key fallback after an extra 13s to avoid simultaneous offers
  setTimeout(() => {
    if (!isOnline(contactPubKeyB64) && getContacts().some(c => c.pubKeyB64 === contactPubKeyB64)) {
      if (_myPubKey && _myPubKey > contactPubKeyB64) {
        console.log(`[p2p] ${shortKey}… reconnect attempt ${attempts} (higher-key fallback)`);
        _autoConnQueue.push({ role: 'initiator', pubKey: contactPubKeyB64 });
        _runAutoConnQueue();
      }
    }
  }, delay + 13000);
}

// Tracks peers where encryption failed -- triggers reconnect on next relay ping
const _peerEncryptionFailed = new Set();

// Creates a DataChannel-compatible object that routes messages through the signal WS.
// Binary data is base64-encoded. JSON messages are E2E encrypted with ECDH shared key.
function _makeRelayDc(pubKeyB64) {
  // Pre-derive shared key for E2E encryption over relay
  let _sharedKey = null;
  const _keyReady = (async () => {
    try {
      const identity = await getIdentity();
      if (identity?.privKey) _sharedKey = await deriveSharedKey(identity.privKey, pubKeyB64);
    } catch(e) {
      console.warn('[p2p] relay key derivation failed:', e);
      _peerEncryptionFailed.add(pubKeyB64);
    }
  })();

  return {
    get readyState() {
      return (_wsRelayPeers.has(pubKeyB64) && _sigWs && _sigWs.readyState === WebSocket.OPEN)
        ? 'open' : 'closed';
    },
    send(data) {
      if (!_sigWs || _sigWs.readyState !== WebSocket.OPEN) return;
      // All relay messages MUST be encrypted -- queue until key is ready, drop if key fails
      _keyReady.then(async () => {
        if (!_sigWs || _sigWs.readyState !== WebSocket.OPEN) return;
        if (!_sharedKey || typeof encryptWithKey !== 'function') {
          console.warn('[p2p] relay key not available -- dropping message (never sending plaintext)');
          _peerEncryptionFailed.add(pubKeyB64);
          return;
        }
        if (data instanceof ArrayBuffer || ArrayBuffer.isView(data)) {
          // Binary data: base64-encode then encrypt as JSON wrapper
          const bytes = data instanceof ArrayBuffer
            ? new Uint8Array(data)
            : new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
          let binary = '';
          for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]);
          try {
            const enc = await encryptWithKey({ _bin: btoa(binary) }, _sharedKey);
            _sigWs.send(JSON.stringify({ type: 'relay', to: pubKeyB64, kind: 'enc', payload: JSON.stringify(enc) }));
          } catch(e) { console.warn('[p2p] relay binary encrypt failed:', e); }
        } else {
          // JSON data: inject relay replay fields then encrypt
          try {
            const obj = JSON.parse(data);
            obj._relayTs = Date.now();
            obj._relayNonce = _randomNonce();
            const enc = await encryptWithKey(obj, _sharedKey);
            _sigWs.send(JSON.stringify({ type: 'relay', to: pubKeyB64, kind: 'enc', payload: JSON.stringify(enc) }));
          } catch(e) { console.warn('[p2p] relay encrypt failed:', e); }
        }
      }).catch(e => console.warn('[p2p] relay send error:', e));
    },
    close() { _wsRelayPeers.delete(pubKeyB64); }
  };
}

function getEphemeralJournal(pubKeyB64) { return _ephemeralJournals.get(pubKeyB64) || null; }

// Per-transfer image state: keyed by "pubKeyB64:entryId:field" for concurrent tracking
// Value: { entryId, field, mimeType, expectedChunks, chunks, received, totalBytes, sha256, startedAt, inactivityTimer }
const _imgTransfer = new Map();

// Byte rate tracking per peer per minute -- prevents flooding
// Map of pubKeyB64 → { bytes: number, resetAt: number }
const _imgBytesPerMin = new Map();
const _IMG_MAX_BYTES_PER_MIN = 10 * 1024 * 1024; // 10MB/min
const _IMG_MAX_CONCURRENT = 3;
const _IMG_INACTIVITY_TIMEOUT = 30000; // 30s

async function _setupDataChannel(dc, contactPubKeyB64, isInitiator) {
  dc.onopen = async () => {
    console.log(`[p2p] DataChannel open with ${contactPubKeyB64.slice(0,8)}…`);
    _wsRelayPeers.delete(contactPubKeyB64); // real DC takes priority over WS relay
    _helloedPeers.delete(contactPubKeyB64); // reset so hello is tracked fresh
    _relayConfirmed.delete(contactPubKeyB64);
    _clearImgDeniedForPeer(contactPubKeyB64); // permissions may have changed
    const entry = peerConns.get(contactPubKeyB64);
    if (entry) entry.dc = dc;
    updatePeerStatus();
    // Verify mutual buddy relationship before sharing anything
    const identity = await getIdentity();
    if (identity) {
      _dcSend(dc, { type: 'hello', pubKey: identity.pubKeyB64, protocolVersion: 2 });
      _helloedPeers.add(contactPubKeyB64);
    }
  };

  dc.onmessage = async event => {
    if (event.data instanceof ArrayBuffer) {
      _handleBinaryChunk(contactPubKeyB64, event.data);
    } else {
      try {
        const msg = JSON.parse(event.data);
        await _handleMessage(contactPubKeyB64, dc, msg);
      } catch(e) { console.warn('Bad message from peer:', e); }
    }
  };

  dc.onclose = () => {
    console.log(`[p2p] DataChannel closed with ${contactPubKeyB64.slice(0,8)}…`);
    const _ki = _keepaliveIntervals.get(contactPubKeyB64);
    if (_ki) { clearInterval(_ki); _keepaliveIntervals.delete(contactPubKeyB64); }
    // If relay mode took over after ICE failure, the relay entry is already in peerConns -- preserve it
    if (peerConns.get(contactPubKeyB64)?.relay) return;
    peerConns.delete(contactPubKeyB64);
    // Clear all image transfers for this peer
    const _imgPeerPrefix = contactPubKeyB64 + ':';
    for (const [k, v] of _imgTransfer) {
      if (k.startsWith(_imgPeerPrefix)) {
        if (v.inactivityTimer) clearTimeout(v.inactivityTimer);
        _imgTransfer.delete(k);
      }
    }
    _imgBytesPerMin.delete(contactPubKeyB64);
    _relayNonces.delete(contactPubKeyB64);
    _hmacKeys.delete(contactPubKeyB64);
    _recentNonces.delete(contactPubKeyB64);
    _peerProtoVersion.delete(contactPubKeyB64);
    _imgReqTimes.delete(contactPubKeyB64);
    _ephemeralJournals.delete(contactPubKeyB64);
    _helloedPeers.delete(contactPubKeyB64);
    _relayConfirmed.delete(contactPubKeyB64);
    _stopRelayPing(contactPubKeyB64);
    const _pendingM = _manifestPending.get(contactPubKeyB64);
    if (_pendingM) { clearTimeout(_pendingM.timeout); _manifestPending.delete(contactPubKeyB64); }
    _evictSessionOnlyImgsForPeer(contactPubKeyB64);
    _buddyTimerStates.delete(contactPubKeyB64);
    updateContactOnline(contactPubKeyB64, false);
    updatePeerStatus();
  };

  dc.onerror = err => console.warn(`[p2p] DataChannel error with ${contactPubKeyB64.slice(0,8)}…:`, err);
}

// ─── Sending journal ──────────────────────────────────────────────────────────

async function _sendMyJournal(dc, contactPubKeyB64) {
  const identity = await getIdentity();
  if (!identity) return;
  const contact = getContacts().find(c => c.pubKeyB64 === contactPubKeyB64);
  if (!contact) return;

  let sharedKey;
  try {
    sharedKey = await deriveSharedKey(identity.privKey, contactPubKeyB64);
  } catch(e) {
    console.error('Could not derive shared key -- contact may have wrong public key:', e);
    return;
  }

  const _bPermsJ = (typeof getBuddyPermissions === 'function') ? getBuddyPermissions() : {};
  const _permJ = resolvePermissionsForContact(contact);
  // Send RAW scopes in payload and hasImgFrontMap -- mutual enforcement at _sendImage only
  const { allowView: rawView } = _rawPhotoScopes(_permJ);
  const { allowView, allowCache } = _wirePhotoScopes(_permJ, contact);
  const _hifMapJ = _buildHasImgFrontMap(_permJ, rawView);

  const entries = getEntries().map(e => ({
    ...e,
    imgFront: null,
    hasImgFront: !!_hifMapJ[e.id],
    wsScan: e.wsScan ? {
      ...e.wsScan,
      photoKey: null, displayKey: null, edgeKey: null, overlayKey: null
    } : null
  }));

  const rawProfile = (typeof getProfile === 'function') ? getProfile() : {};
  const hiddenFields = new Set(_bPermsJ.hiddenProfileFields || []);
  const sharedProfile = {};
  for (const [k, v] of Object.entries({
    goalCI: rawProfile.goalCI ?? null,
    startingCI: rawProfile.startingCI ?? null,
    startDate: rawProfile.startDate || null,
    ageCircumcised: rawProfile.ageCircumcised ?? null,
    circumStyle: rawProfile.circumStyle || null,
    circumMethod: rawProfile.circumMethod || null,
    fullyRestoredOn: rawProfile.fullyRestoredOn || null,
  })) {
    sharedProfile[k] = hiddenFields.has(k) ? null : v;
  }

  const avatar = rawProfile.avatar || null;

  const vanishTTL      = (typeof getVanishSettings === 'function') ? (getVanishSettings().ttl ?? 90) : 90;
  const encryptionEnabled = (typeof idbMetaGet === 'function') ? !!(await idbMetaGet('encryptionEnabled')) : false;

  try {
    const payload = await encryptWithKey(
      { displayName: identity.displayName, avatar, entries, trusted: !!contact.trusted, profile: sharedProfile, vanishTTL, encryptionEnabled, allowView, allowCache, hideFromBuddyList: !!_bPermsJ.hideMeFromBuddyLists },
      sharedKey
    );
    _dcSend(dc, { type: 'journal', iv: payload.iv, data: payload.data });
  } catch(e) {
    console.error('Journal encryption failed:', e);
  }
}

// ─── Permission resolution ────────────────────────────────────────────────────
// Single source of truth: what am I granting to this specific contact?
// Uses my Trusted tier if I've marked them trusted, Buddy tier otherwise.
function resolvePermissionsForContact(contact) {
  const perms = typeof getBuddyPermissions === 'function' ? getBuddyPermissions() : {};
  // Tier reciprocity: use trusted tier only when trust is mutual (both sides trust each other).
  // If I trust them but they don't trust me back, use buddy tier -- the trust isn't reciprocated.
  const mutualTrust = !!(contact?.trusted && contact?.senderTrusts);
  const tier  = (mutualTrust && perms.trusted) ? perms.trusted : (perms.buddy || {});
  return {
    viewLatest:  !!tier.viewLatestImg,
    cacheLatest: !!tier.cacheLatestImg,
    viewAll:     !!tier.viewAllImgs,
    cacheAll:    !!tier.cacheAllImgs,
    timerStatus: !!tier.timerStatus,
    noWatermark: !!tier.noWatermark,
  };
}

// ── Mutual photo scope helpers ────────────────────────────────────────────────
// Returns the lower of two scope strings: 'none' < 'latest' < 'all'.
// theirScope === undefined means "haven't heard from them yet" → restrict to 'none' (safe default).
const _SCOPE_ORDER = ['none', 'latest', 'all'];
function _mutualScope(myScope, theirScope) {
  if (theirScope === undefined) return 'none';
  const mine   = _SCOPE_ORDER.indexOf(myScope);
  const theirs = _SCOPE_ORDER.indexOf(theirScope);
  if (mine < 0 || theirs < 0) return 'none';
  return _SCOPE_ORDER[Math.min(mine, theirs)];
}

// Returns true if the peer actually has at least one photo behind their claimed scope.
// Catches "mutual enabled but no photo uploaded" -- senderViewScope may be 'latest'/'all'
// while every entry has hasImgFront: false (setting enabled, nothing ever uploaded).
// Also primes imgURL's background fetch so that on the next cycle the photo may be
// confirmed loaded -- fake senders who never deliver the blob will get no benefit from
// their hasImgFront: true claim once p2pIsImgDenied / failed fetches mark them.
// senderViewScope === undefined → haven't heard from them yet → assume no photos (safe default).
function _theyHaveLoadablePhoto(contact) {
  if (contact.senderViewScope === undefined) return false;
  if (contact.senderViewScope === 'none') return false;
  const entries = contact.journal?.entries || [];
  const claimedEntries = entries.filter(e => e.hasImgFront);
  if (!claimedEntries.length) return false; // declared scope but zero photos behind it
  // Prime the imgCache background fetch for their photos (no-op if already loading)
  const prefix = `c_${(contact.pubKeyB64 || '').slice(0, 16)}_`;
  claimedEntries.forEach(e => imgURL(`${prefix}${e.id}_front`));
  return true;
}

// Raw scopes -- what I'm granting this contact, with NO mutual capping.
// Raw scope intent -- NOT sent in payloads directly. Used for hasImgFrontMap.
function _rawPhotoScopes(perm) {
  return {
    allowView:  perm.viewAll  ? 'all' : perm.viewLatest  ? 'latest' : 'none',
    allowCache: perm.cacheAll ? 'all' : perm.cacheLatest ? 'latest' : 'none'
  };
}

// Wire scopes for payloads: raw allowView (so buddy knows we share), but
// mutual-capped allowCache (so buddy only caches if both sides allow it).
// No deadlock risk -- deadlock was specific to hasImgFrontMap, not scope strings.
function _wirePhotoScopes(perm, contact) {
  const raw = _rawPhotoScopes(perm);
  const _bPerms = typeof getBuddyPermissions === 'function' ? getBuddyPermissions() : {};
  if (_bPerms.requireMutualPhoto === false) return raw;
  // Cap cache to min(myCache, theirCache) and never exceed view
  const effCache = _mutualScope(raw.allowCache, contact?.senderCacheScope);
  const cappedCache = _mutualScope(effCache, raw.allowView);
  return { allowView: raw.allowView, allowCache: cappedCache };
}

// Returns { allowView, allowCache } after applying requireMutualPhoto capping.
// Used ONLY for local enforcement (_sendImage decisions).
// NOT sent in payloads to avoid
// double mutual-capping deadlock (both sides cap → both become 'none' forever).
function _effectivePhotoScopes(perm, contact) {
  const myView  = perm.viewAll  ? 'all' : perm.viewLatest  ? 'latest' : 'none';
  const myCache = perm.cacheAll ? 'all' : perm.cacheLatest ? 'latest' : 'none';
  const _bPerms = typeof getBuddyPermissions === 'function' ? getBuddyPermissions() : {};
  if (_bPerms.requireMutualPhoto === false) return { allowView: myView, allowCache: myCache };

  // Cap scope to what they're sharing with us (the core mutual check)
  let allowView  = _mutualScope(myView,  contact.senderViewScope);
  let allowCache = _mutualScope(_mutualScope(myCache, contact.senderCacheScope), allowView);

  if (allowView !== 'none') {
    // They must have an actual photo behind their scope claim
    if (!_theyHaveLoadablePhoto(contact)) {
      allowView = 'none'; allowCache = 'none';
    } else {
      // We must also have at least one photo to share -- no point sending allowView > 'none'
      // if we've enabled sharing but never uploaded anything (nothing in our own entries)
      const weHavePhoto = myView !== 'none' &&
        getEntries().some(e => e.imgFront && !e.imgPrivate);
      if (!weHavePhoto) { allowView = 'none'; allowCache = 'none'; }
    }
  }

  return { allowView, allowCache };
}

// Build hasImgFrontMap for a contact based on view scope.
// hasImgFrontMap[id]=true means "I'm allowing this entry's photo to be seen".
// Pass effectiveViewScope ('none'|'latest'|'all') to override perm's raw values.
function _buildHasImgFrontMap(perm, effectiveViewScope) {
  const scope = effectiveViewScope !== undefined
    ? effectiveViewScope
    : (perm.viewAll ? 'all' : perm.viewLatest ? 'latest' : 'none');
  const myEntries = getEntries()
    .filter(e => e.imgFront && !e.imgPrivate)
    .sort((a, b) => b.date.localeCompare(a.date));
  const latestId = myEntries[0]?.id;
  const visibleIds = new Set(
    scope === 'all'    ? myEntries.map(e => e.id) :
    scope === 'latest' ? (latestId ? [latestId] : []) :
    []
  );
  const map = {};
  for (const e of getEntries()) map[e.id] = visibleIds.has(e.id);
  return map;
}

// ─── Lightweight settings push ────────────────────────────────────────────────
// Sends trust/permissions/hasImgFront flags -- no entry content.
// Used instead of a full journal resend when only settings change.

async function _sendSettingsPush(dc, contactPubKeyB64) {
  const contact = getContacts().find(c => c.pubKeyB64 === contactPubKeyB64);
  if (!contact) return;
  const perm = resolvePermissionsForContact(contact);
  const { allowView: rawView } = _rawPhotoScopes(perm);
  const { allowView, allowCache } = _wirePhotoScopes(perm, contact);
  const _bPerms = typeof getBuddyPermissions === 'function' ? getBuddyPermissions() : {};
  const body = {
    trusted: !!contact.trusted,
    vanishTTL: (typeof getVanishSettings === 'function') ? (getVanishSettings().ttl ?? 90) : 90,
    allowView,
    allowCache,
    hiddenProfileFields: _bPerms.hiddenProfileFields || [],
    hasImgFrontMap: _buildHasImgFrontMap(perm, rawView),
    hideFromBuddyList: !!_bPerms.hideMeFromBuddyLists,
    ts: Date.now(),
    nonce: _randomNonce()
  };
  try {
    const hmacKey = await _getHmacKey(contactPubKeyB64);
    if (hmacKey) {
      const sigPayload = {
        trusted: body.trusted, vanishTTL: body.vanishTTL, allowView: body.allowView,
        allowCache: body.allowCache, hiddenProfileFields: body.hiddenProfileFields,
        hasImgFrontMap: body.hasImgFrontMap, hideFromBuddyList: body.hideFromBuddyList,
        ts: body.ts, nonce: body.nonce
      };
      body.sig = await hmacSign(sigPayload, hmacKey);
    }
  } catch(e) { console.warn('[p2p] settings_push signing failed:', e); }
  _dcSend(dc, { type: 'settings_push', ...body });
}

async function p2pSendSettingsPush(pubKeyB64) {
  const dc = _getDC(pubKeyB64);
  if (!dc || dc.readyState !== 'open') return;
  await _sendSettingsPush(dc, pubKeyB64);
}

async function p2pSendTrustAck(pubKeyB64, trusted) {
  const dc = _getDC(pubKeyB64);
  if (!dc || dc.readyState !== 'open') return;
  const body = { trusted: !!trusted, ts: Date.now(), nonce: _randomNonce() };
  try {
    const hmacKey = await _getHmacKey(pubKeyB64);
    if (hmacKey) body.sig = await hmacSign({ trusted: body.trusted, ts: body.ts, nonce: body.nonce }, hmacKey);
  } catch(e) { console.warn('[p2p] trust_ack signing failed:', e); }
  _dcSend(dc, { type: 'trust_ack', ...body });
}

// After receiving updated scopes from a peer, re-send our settings so they
// get a fresh hasImgFrontMap reflecting the new mutual state. Only fires when
// senderViewScope or senderCacheScope actually changed -- prevents loops.
async function _resendIfScopeChanged(contactPubKeyB64, prevViewScope, prevCacheScope) {
  const contact = getContacts().find(c => c.pubKeyB64 === contactPubKeyB64);
  if (!contact) return;
  if (contact.senderViewScope === prevViewScope && contact.senderCacheScope === prevCacheScope) return;
  const dc = _getDC(contactPubKeyB64);
  if (dc?.readyState === 'open') {
    console.log(`[p2p] peer scope changed -- resending settings to ${contactPubKeyB64.slice(0,8)}…`);
    await _sendSettingsPush(dc, contactPubKeyB64);
  }
}

// Internal helper: send signed trust_ack on a known-open dc
async function _sendSignedTrustAck(dc, pubKeyB64, trusted) {
  const body = { trusted: !!trusted, ts: Date.now(), nonce: _randomNonce() };
  try {
    const hmacKey = await _getHmacKey(pubKeyB64);
    if (hmacKey) body.sig = await hmacSign({ trusted: body.trusted, ts: body.ts, nonce: body.nonce }, hmacKey);
  } catch(e) { console.warn('[p2p] trust_ack signing failed:', e); }
  _dcSend(dc, { type: 'trust_ack', ...body });
}

async function p2pBroadcastSettingsPush() {
  for (const [pubKeyB64] of peerConns) {
    const dc = _getDC(pubKeyB64);
    if (dc?.readyState === 'open') await _sendSettingsPush(dc, pubKeyB64);
  }
}

// ─── Buddy list sharing ───────────────────────────────────────────────────────
// Sends this user's buddy list (emoji + name + hash only) to a single peer.
async function _sendBuddyList(dc, contactPubKeyB64) {
  const perms = typeof getBuddyPermissions === 'function' ? getBuddyPermissions() : {};
  const contact = getContacts().find(c => c.pubKeyB64 === contactPubKeyB64);
  if (!contact) return;
  const tier = (contact.trusted && perms.trusted) ? perms.trusted : (perms.buddy || {});
  if (!tier.shareBuddyList) return;
  const contacts = getContacts();
  const candidates = contacts.filter(c =>
    c.pubKeyB64 !== contactPubKeyB64 &&
    !c.removedAt && !c.forgottenAt && !c.notBuddiesYet &&
    !c.hideFromBuddyList
  ).slice(0, 50);
  const buddies = [];
  for (const c of candidates) {
    const hash = await _hashRawPubKey(c.pubKeyB64);
    buddies.push({
      hash,
      emoji: c.journal?.avatar || null,
      name: c.journal?.displayName || c.nickname || null
    });
  }
  _dcSend(dc, { type: 'buddy_list', buddies });
}

// Broadcast buddy list to all connected peers
async function p2pBroadcastBuddyList() {
  for (const [pubKeyB64] of peerConns) {
    const dc = _getDC(pubKeyB64);
    if (dc?.readyState === 'open') await _sendBuddyList(dc, pubKeyB64);
  }
}

// Broadcast a journal manifest to all connected buddies.
// Used after saving an entry or updating profile -- buddies pull only what changed.
async function p2pBroadcastManifest() {
  for (const [pubKeyB64] of peerConns) {
    const dc = _getDC(pubKeyB64);
    if (dc?.readyState === 'open') await _sendJournalManifest(dc, pubKeyB64);
  }
}

// ─── Phase 2: Manifest-based delta sync ──────────────────────────────────────
// Instead of sending the full journal, sender sends a lightweight manifest
// (entry IDs + updatedAt timestamps). Receiver replies requesting only the
// entries it doesn't have or that are newer. Sender sends only those entries.
// Old clients (no journal_pull in 3s) automatically get a full journal fallback.

// pubKeyB64 → { dc, timeout } -- pending manifests awaiting a pull response
const _manifestPending = new Map();

async function _sendJournalManifest(dc, contactPubKeyB64) {
  const identity = await getIdentity();
  if (!identity) return;
  const contact = getContacts().find(c => c.pubKeyB64 === contactPubKeyB64);
  if (!contact) return;

  const perm = resolvePermissionsForContact(contact);
  const { allowView: rawView } = _rawPhotoScopes(perm);
  const { allowView, allowCache } = _wirePhotoScopes(perm, contact);
  const _bPerms = typeof getBuddyPermissions === 'function' ? getBuddyPermissions() : {};
  const manifestEntries = getEntries().map(e => ({ id: e.id, updatedAt: e.updatedAt || e.date }));
  const rawProfile = (typeof getProfile === 'function') ? getProfile() : {};
  const profileTs  = rawProfile.updatedAt || 0;

  // Cancel any previous pending manifest for this peer
  const prev = _manifestPending.get(contactPubKeyB64);
  if (prev) clearTimeout(prev.timeout);

  _dcSend(dc, {
    type: 'journal_manifest',
    entries: manifestEntries,
    profileTs,
    meta: {
      trusted: !!contact.trusted,
      vanishTTL: (typeof getVanishSettings === 'function') ? (getVanishSettings().ttl ?? 90) : 90,
      allowView,
      allowCache,
      hiddenProfileFields: _bPerms.hiddenProfileFields || [],
      hasImgFrontMap: _buildHasImgFrontMap(perm, rawView),
      hideFromBuddyList: !!_bPerms.hideMeFromBuddyLists
    }
  });

  // 3s fallback -- if peer doesn't reply with journal_pull, they don't speak this protocol
  const timeout = setTimeout(async () => {
    _manifestPending.delete(contactPubKeyB64);
    if (dc.readyState !== 'open') return;
    if (peerConns.get(contactPubKeyB64)?.dc !== dc) return; // stale channel -- new one already set up
    console.log(`[p2p] manifest: no pull from ${contactPubKeyB64.slice(0,8)}… in 3s -- sending full journal`);
    await _sendMyJournal(dc, contactPubKeyB64);
  }, 3000);
  _manifestPending.set(contactPubKeyB64, { dc, timeout });
}

async function _handleJournalManifest(contactPubKeyB64, dc, msg) {
  // Peer speaks manifest protocol -- cancel our fallback timer if still pending
  const pending = _manifestPending.get(contactPubKeyB64);
  if (pending) { clearTimeout(pending.timeout); _manifestPending.delete(contactPubKeyB64); }

  const contacts = getContacts();
  const i = contacts.findIndex(c => c.pubKeyB64 === contactPubKeyB64);
  if (i < 0) return;

  // Apply settings from meta immediately (no full journal needed for settings)
  if (msg.meta && typeof msg.meta === 'object') {
    const meta = msg.meta;
    if (typeof meta.trusted === 'boolean') contacts[i].senderTrusts = meta.trusted;
    if (meta.vanishTTL != null)            contacts[i].vanishTTL      = Number(meta.vanishTTL);
    // v2: allowCache/allowView are "none"|"latest"|"all"; v1 compat: bool
    const metaCacheVal = typeof meta.allowCache === 'string' ? meta.allowCache : (meta.allowCache === true ? 'latest' : 'none');
    const metaViewVal  = typeof meta.allowView  === 'string' ? meta.allowView  : (meta.allowView  === true ? 'latest' : metaCacheVal !== 'none' ? 'latest' : 'none');
    const _prevView = contacts[i].senderViewScope, _prevCache = contacts[i].senderCacheScope;
    contacts[i].senderAllowsCache = metaCacheVal !== 'none';
    contacts[i].senderCacheScope  = metaCacheVal;
    contacts[i].senderViewScope   = metaViewVal;
    if (typeof meta.hideFromBuddyList === 'boolean') contacts[i].hideFromBuddyList = meta.hideFromBuddyList;
    if (meta.hasImgFrontMap && typeof meta.hasImgFrontMap === 'object' && contacts[i].journal?.entries) {
      for (const entry of contacts[i].journal.entries) {
        if (entry.id in meta.hasImgFrontMap) entry.hasImgFront = !!meta.hasImgFrontMap[entry.id];
      }
    }
    saveContacts(contacts);
    _resendIfScopeChanged(contactPubKeyB64, _prevView, _prevCache);
  }

  // Prune local entries that are no longer in the sender's manifest (deleted by sender)
  const theirEntries = Array.isArray(msg.entries) ? msg.entries : [];
  const theirIdSet = new Set(theirEntries.map(e => e.id));
  if (contacts[i].journal?.entries) {
    const before = contacts[i].journal.entries.length;
    contacts[i].journal.entries = contacts[i].journal.entries.filter(e => theirIdSet.has(e.id));
    if (contacts[i].journal.entries.length < before) { saveContacts(contacts); }
  }

  // Determine which entries we need
  const myEntries    = contacts[i].journal?.entries || [];
  const myEntryMap   = new Map(myEntries.map(e => [e.id, e.updatedAt || e.date]));
  const neededIds    = [];
  for (const e of theirEntries) {
    if (typeof e.id !== 'string' || !/^[A-Za-z0-9_-]{1,64}$/.test(e.id)) continue;
    const myTs   = myEntryMap.get(e.id);
    const theirTs = e.updatedAt || null;
    if (myTs === undefined || (theirTs && theirTs > myTs)) neededIds.push(e.id);
  }

  const needProfile = !contacts[i].journal
    || !contacts[i].journal._profileTs
    || (msg.profileTs && msg.profileTs > contacts[i].journal._profileTs);

  console.log(`[p2p] manifest from ${contactPubKeyB64.slice(0,8)}…: ${theirEntries.length} entries, need ${neededIds.length}, profile=${!!needProfile}`);
  _dcSend(dc, { type: 'journal_pull', entryIds: neededIds, needProfile: !!needProfile });
}

async function _handleJournalPull(contactPubKeyB64, dc, msg) {
  // Peer speaks the protocol -- cancel the fallback timeout
  const pending = _manifestPending.get(contactPubKeyB64);
  if (pending) { clearTimeout(pending.timeout); _manifestPending.delete(contactPubKeyB64); }

  const identity = await getIdentity();
  if (!identity) return;
  const contact = getContacts().find(c => c.pubKeyB64 === contactPubKeyB64);
  if (!contact) return;

  let sharedKey;
  try { sharedKey = await deriveSharedKey(identity.privKey, contactPubKeyB64); }
  catch(e) {
    console.error('[p2p] journal_pull key derivation failed:', e);
    try { _dcSend(dc, { type: 'manifest_error', reason: 'key_derivation' }); } catch(_) {}
    return;
  }

  const requestedIds = new Set(
    Array.isArray(msg.entryIds)
      ? msg.entryIds.filter(id => typeof id === 'string' && /^[A-Za-z0-9_-]{1,64}$/.test(id)).slice(0, 500)
      : []
  );

  const _bPerms = typeof getBuddyPermissions === 'function' ? getBuddyPermissions() : {};
  const perm = resolvePermissionsForContact(contact);
  const { allowView: rawView } = _rawPhotoScopes(perm);
  const { allowView, allowCache } = _wirePhotoScopes(perm, contact);
  const _hifMap = _buildHasImgFrontMap(perm, rawView);

  const patchEntries = getEntries()
    .filter(e => requestedIds.has(e.id))
    .map(e => ({
      ...e, imgFront: null,
      hasImgFront: !!_hifMap[e.id],
      wsScan: e.wsScan ? { ...e.wsScan, photoKey: null, displayKey: null, edgeKey: null, overlayKey: null } : null
    }));

  let sharedProfile = null;
  if (msg.needProfile) {
    const rawProfile  = (typeof getProfile === 'function') ? getProfile() : {};
    const hiddenFields = new Set(_bPerms.hiddenProfileFields || []);
    sharedProfile = { _ts: rawProfile.updatedAt || Date.now() };
    for (const [k, v] of Object.entries({
      goalCI: rawProfile.goalCI ?? null, startingCI: rawProfile.startingCI ?? null,
      startDate: rawProfile.startDate || null, ageCircumcised: rawProfile.ageCircumcised ?? null,
      circumStyle: rawProfile.circumStyle || null, circumMethod: rawProfile.circumMethod || null,
      fullyRestoredOn: rawProfile.fullyRestoredOn || null,
    })) { sharedProfile[k] = hiddenFields.has(k) ? null : v; }
  }

  const vanishTTL = (typeof getVanishSettings === 'function') ? (getVanishSettings().ttl ?? 90) : 90;
  const encryptionEnabled = (typeof idbMetaGet === 'function') ? !!(await idbMetaGet('encryptionEnabled')) : false;
  const patchPayload = {
    displayName: identity.displayName,
    avatar: msg.needProfile ? ((typeof getProfile === 'function') ? getProfile().avatar : null) : undefined,
    entries: patchEntries, trusted: !!contact.trusted,
    vanishTTL, encryptionEnabled, allowView, allowCache,
    ...(sharedProfile ? { profile: sharedProfile } : {})
  };

  console.log(`[p2p] sending patch to ${contactPubKeyB64.slice(0,8)}…: ${patchEntries.length}/${requestedIds.size} entries`);
  try {
    const enc = await encryptWithKey(patchPayload, sharedKey);
    _dcSend(dc, { type: 'journal_patch', iv: enc.iv, data: enc.data });
  } catch(e) {
    console.error('[p2p] journal_patch encryption failed:', e);
    try { _dcSend(dc, { type: 'manifest_error', reason: 'encryption' }); } catch(_) {}
  }
}

async function _handleJournalPatch(contactPubKeyB64, dc, msg) {
  const identity = await getIdentity();
  const contacts  = getContacts();
  const contact   = contacts.find(c => c.pubKeyB64 === contactPubKeyB64);
  if (!contact || !identity) return;

  let sharedKey;
  try { sharedKey = await deriveSharedKey(identity.privKey, contactPubKeyB64); }
  catch(e) {
    console.error('[p2p] journal_patch key derivation failed:', e);
    return;
  }

  try {
    const raw = await decryptWithKey({ iv: msg.iv, data: msg.data }, sharedKey);
    if (!raw || typeof raw !== 'object') return;
    const i = contacts.findIndex(c => c.pubKeyB64 === contactPubKeyB64);
    if (i < 0) return;

    const prevJournal = contacts[i].journal || _ephemeralJournals.get(contactPubKeyB64);
    const prevIds     = new Set((prevJournal?.entries || []).map(e => e.id));
    const prevSenderTrusts = contacts[i].senderTrusts;

    // Merge incoming entries -- MERGE not replace
    const existingEntries = contacts[i].journal?.entries || [];
    const entryMap = new Map(existingEntries.map(e => [e.id, e]));
    for (const rawEntry of (raw.entries || [])) {
      const safe = _sanitizeEntry(rawEntry);
      if (safe) entryMap.set(safe.id, safe);
    }

    // v2: allowCache/allowView are "none"|"latest"|"all"; v1 compat: bool
    const patchCacheVal = typeof raw.allowCache === 'string' ? raw.allowCache : (raw.allowCache === true ? 'latest' : 'none');
    const patchViewVal  = typeof raw.allowView  === 'string' ? raw.allowView  : (raw.allowView  === true ? 'latest' : patchCacheVal !== 'none' ? 'latest' : 'none');
    const _prevView2 = contacts[i].senderViewScope, _prevCache2 = contacts[i].senderCacheScope;
    contacts[i].lastSeen          = new Date().toISOString();
    contacts[i].lastJournalSync   = new Date().toISOString();
    contacts[i].senderTrusts      = raw.trusted === true;
    contacts[i].vanishTTL         = raw.vanishTTL ?? contacts[i].vanishTTL;
    contacts[i].peerEncryptionEnabled = raw.encryptionEnabled === true;
    contacts[i].senderAllowsCache = patchCacheVal !== 'none';
    contacts[i].senderCacheScope  = patchCacheVal;
    contacts[i].senderViewScope   = patchViewVal;
    contacts[i].vanished          = false;

    if (!contacts[i].journal) contacts[i].journal = {};
    contacts[i].journal.entries  = Array.from(entryMap.values());
    contacts[i].journal.trusted  = raw.trusted === true;
    contacts[i].journal.vanishTTL = raw.vanishTTL;
    contacts[i].journal.allowCache = patchCacheVal !== 'none';
    if (typeof raw.hideFromBuddyList === 'boolean') contacts[i].hideFromBuddyList = raw.hideFromBuddyList;
    if (raw.displayName) { contacts[i].nickname = raw.displayName; contacts[i].journal.displayName = raw.displayName; }
    if (raw.avatar !== undefined) contacts[i].journal.avatar = raw.avatar;
    if (raw.profile) {
      contacts[i].journal.profile = raw.profile;
      if (raw.profile._ts) contacts[i].journal._profileTs = raw.profile._ts;
    }
    saveContacts(contacts);
    _resendIfScopeChanged(contactPubKeyB64, _prevView2, _prevCache2);

    if (typeof renderContactsList    === 'function') renderContactsList();
    if (typeof refreshPeerJournalPanel === 'function') refreshPeerJournalPanel(contactPubKeyB64);
    if (typeof _renderDashProfile    === 'function') _renderDashProfile();
    if (typeof buddyRipple           === 'function') buddyRipple();

    // Mutual trust newly established → tell them (same as journal handler)
    if (!prevSenderTrusts && contacts[i].senderTrusts && contacts[i].trusted) {
      console.log(`[p2p] mutual trust via patch from ${contactPubKeyB64.slice(0,8)}… -- sending trust_ack + settings`);
      await _sendSignedTrustAck(dc, contactPubKeyB64, true);
      await _sendSettingsPush(dc, contactPubKeyB64);
    }

    // Photo eviction (mirrors journal handler)
    const allEntries     = contacts[i].journal.entries;
    const allowedEntryIds = new Set(allEntries.filter(e => e.hasImgFront).map(e => e.id));
    const cachePrefix    = `c_${contactPubKeyB64.slice(0, 16)}_`;
    const mutualTrustNow = contacts[i].trusted && contacts[i].senderTrusts;
    const cacheAllowed   = mutualTrustNow || contacts[i].senderAllowsCache;
    try {
      const allKeys  = await idbGetAllKeys();
      const staleKeys = allKeys.filter(k => {
        if (!k.startsWith(cachePrefix)) return false;
        if (!cacheAllowed) return true;
        return !allowedEntryIds.has(k.slice(cachePrefix.length).split('_').slice(0, -1).join('_'));
      });
      if (staleKeys.length) {
        await Promise.all(staleKeys.map(k => idbDel(k)));
        for (const k of staleKeys) {
          if (imgCache[k]) { URL.revokeObjectURL(imgCache[k]); delete imgCache[k]; }
          _sessionOnlyImgKeys.delete(k);
        }
      }
      if (!cacheAllowed) {
        for (const key of Object.keys(imgCache)) {
          if (key.startsWith(cachePrefix)) {
            URL.revokeObjectURL(imgCache[key]); delete imgCache[key]; _sessionOnlyImgKeys.delete(key);
          }
        }
      }
    } catch(e) { console.warn('[p2p] patch eviction error:', e); }

    // Proactive photo fetch
    const sorted = [...allEntries].sort((a, b) => b.date.localeCompare(a.date));
    if (mutualTrustNow) {
      sorted.filter(e => e.hasImgFront).forEach((e, idx) => {
        if (idx === 0) p2pRequestImage(contactPubKeyB64, e.id, 'front');
        else setTimeout(() => p2pRequestImage(contactPubKeyB64, e.id, 'front'), idx * 3500);
      });
    } else {
      const latest = sorted.find(e => e.hasImgFront);
      if (latest) p2pRequestImage(contactPubKeyB64, latest.id, 'front');
    }

    // Notifications for newly arrived entries
    const name = contacts[i].nickname || contacts[i].journal?.displayName || 'Buddy';
    const newEntries = (raw.entries || []).filter(e => e.id && !prevIds.has(e.id));
    for (const ne of newEntries) {
      if (typeof pushNotification === 'function') {
        if (ne.ci != null || ne.hasImgFront) {
          pushNotification('journal', `${name} posted a journal update`, '', {
            tag: `entry-${ne.id}`,
            nav: { tab: 'contacts', pubKeyB64: contactPubKeyB64, entryId: ne.id },
          });
        } else if (ne.pctTugged != null || ne.avgHrsTugged != null) {
          pushNotification('weekly', `${name} logged a weekly session`, '', {
            tag: `entry-${ne.id}`,
            nav: { tab: 'contacts', pubKeyB64: contactPubKeyB64, entryId: ne.id },
          });
        }
      }
    }
    if (!_sessionSynced.has(contactPubKeyB64)) _sessionSynced.add(contactPubKeyB64);
    console.log(`[p2p] patch applied from ${contactPubKeyB64.slice(0,8)}…: merged ${raw.entries?.length ?? 0} entries`);
  } catch(e) { console.error('[p2p] journal_patch decrypt/apply failed:', e); }
}

// ─── Incoming journal validation ────────────────────────────────────────────

const _MAX_STR = 4096;
const _MAX_ENTRIES = 500;
const _ALLOWED_DEVICES = /^[A-Za-z0-9 \-\/()]{1,64}$/;

function _sanitizeStr(v, maxLen) {
  if (v == null) return '';
  return sanitizeUnicode(String(v)).slice(0, maxLen);
}

function _sanitizeEntry(raw) {
  if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
  const safe = {};

  // id: must be a safe string
  if (typeof raw.id !== 'string' || !/^[A-Za-z0-9_-]{1,64}$/.test(raw.id)) return null;
  safe.id = raw.id;

  // date: must be ISO date YYYY-MM-DD
  if (typeof raw.date !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(raw.date)) return null;
  safe.date = raw.date;

  // ci: 0–10 integer or null (tugging-only entries have no CI reading)
  const ci = Number(raw.ci);
  safe.ci = (raw.ci != null && !isNaN(ci) && ci >= 0 && ci <= 10) ? Math.floor(ci) : null;

  // numeric fields: 0–100 or 0–24
  const pctTugged = Number(raw.pctTugged);
  safe.pctTugged = (!isNaN(pctTugged) && pctTugged >= 0 && pctTugged <= 100) ? Math.round(pctTugged) : null;
  const pctRetained = Number(raw.pctRetained);
  safe.pctRetained = (!isNaN(pctRetained) && pctRetained >= 0 && pctRetained <= 100) ? Math.round(pctRetained) : null;
  const avgHrsTugged = Number(raw.avgHrsTugged);
  safe.avgHrsTugged = (!isNaN(avgHrsTugged) && avgHrsTugged >= 0 && avgHrsTugged <= 24) ? avgHrsTugged : null;
  const avgHrsRetained = Number(raw.avgHrsRetained);
  safe.avgHrsRetained = (!isNaN(avgHrsRetained) && avgHrsRetained >= 0 && avgHrsRetained <= 24) ? avgHrsRetained : null;

  // device arrays: only known patterns
  safe.tuggingDevices = Array.isArray(raw.tuggingDevices)
    ? raw.tuggingDevices.filter(d => typeof d === 'string' && _ALLOWED_DEVICES.test(d)).slice(0, 20)
    : [];
  safe.retainingDevices = Array.isArray(raw.retainingDevices)
    ? raw.retainingDevices.filter(d => typeof d === 'string' && _ALLOWED_DEVICES.test(d)).slice(0, 20)
    : [];

  // image key: reference only (no blob data)
  safe.imgFront = (typeof raw.imgFront === 'string' && /^[A-Za-z0-9_-]{1,64}$/.test(raw.imgFront)) ? raw.imgFront : null;
  // hasImgFront flag -- sender strips actual key but sets this so receiver knows to request
  safe.hasImgFront = raw.hasImgFront === true || raw.imgFront != null;

  // wsScan: keep numeric zone results only, drop image keys
  if (raw.wsScan && typeof raw.wsScan === 'object' && !Array.isArray(raw.wsScan)) {
    const ws = raw.wsScan;
    const zr = Array.isArray(ws.zoneResults)
      ? ws.zoneResults.slice(0, 20).map(z => {
          if (!z || typeof z !== 'object') return null;
          const name = _sanitizeStr(z.name, 64);
          const count = Number(z.count);
          const color = (typeof z.color === 'string' && /^#[0-9a-fA-F]{3,6}$/.test(z.color)) ? z.color : '#0057ff';
          return { name, count: isNaN(count) ? 0 : Math.max(0, Math.floor(count)), color };
        }).filter(Boolean)
      : [];
    // Drop all image keys from received wsScan -- we don't have them locally
    safe.wsScan = { zoneResults: zr };
  } else {
    safe.wsScan = null;
  }

  const bmi = Number(raw.bmi);
  safe.bmi = (!isNaN(bmi) && bmi >= 10 && bmi <= 60) ? bmi : null;
  // hrsPerDay stats: computed from timer sessions, used by dashboard for activity bars + sparklines
  const hrsPerDayTugged = Number(raw.hrsPerDayTugged);
  safe.hrsPerDayTugged = (!isNaN(hrsPerDayTugged) && hrsPerDayTugged >= 0 && hrsPerDayTugged <= 24) ? hrsPerDayTugged : null;
  const hrsPerDayRetained = Number(raw.hrsPerDayRetained);
  safe.hrsPerDayRetained = (!isNaN(hrsPerDayRetained) && hrsPerDayRetained >= 0 && hrsPerDayRetained <= 24) ? hrsPerDayRetained : null;
  // weekly stat enriched fields
  const daysTugged = Number(raw.daysTugged);
  safe.daysTugged = (!isNaN(daysTugged) && daysTugged >= 0 && daysTugged <= 7) ? Math.round(daysTugged) : null;
  const daysRetained = Number(raw.daysRetained);
  safe.daysRetained = (!isNaN(daysRetained) && daysRetained >= 0 && daysRetained <= 7) ? Math.round(daysRetained) : null;
  const totalHrsTugged = Number(raw.totalHrsTugged);
  safe.totalHrsTugged = (!isNaN(totalHrsTugged) && totalHrsTugged >= 0 && totalHrsTugged <= 168) ? totalHrsTugged : null;
  const totalHrsRetained = Number(raw.totalHrsRetained);
  safe.totalHrsRetained = (!isNaN(totalHrsRetained) && totalHrsRetained >= 0 && totalHrsRetained <= 168) ? totalHrsRetained : null;

  safe.supplements = _sanitizeStr(raw.supplements, _MAX_STR);
  safe.notes = _sanitizeStr(raw.notes, _MAX_STR);

  // Preserve updatedAt for manifest delta-sync (LWW comparison)
  if (typeof raw.updatedAt === 'string' && raw.updatedAt.length <= 30) safe.updatedAt = raw.updatedAt;
  else if (typeof raw.updatedAt === 'number' && isFinite(raw.updatedAt)) safe.updatedAt = raw.updatedAt;

  return safe;
}

function _sanitizeJournal(raw) {
  if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
  const safe = {};
  safe.displayName = _sanitizeStr(raw.displayName, 128) || 'Peer';
  // Avatar: single emoji character, max 8 bytes, no scripts
  safe.avatar = (typeof raw.avatar === 'string' && raw.avatar.length <= 8 && /^\p{Emoji}/u.test(raw.avatar)) ? raw.avatar : null;
  safe.trusted = raw.trusted === true; // sender trusts us (allows all photos / mutual trust signal)
  safe.entries = Array.isArray(raw.entries)
    ? raw.entries.slice(0, _MAX_ENTRIES).map(_sanitizeEntry).filter(Boolean)
    : [];
  // Shared profile fields
  const p = (raw.profile && typeof raw.profile === 'object') ? raw.profile : {};
  const _ci = v => { const n = Number(v); return (!isNaN(n) && n >= 0 && n <= 10) ? Math.floor(n) : null; };
  const _age = v => { const n = Number(v); return (!isNaN(n) && n >= 0 && n <= 80) ? Math.floor(n) : null; };
  const CIRC_STYLES = ['Average','High & Tight','High & Loose','Low & Tight','Low & Loose'];
  const CIRC_METHODS = ['Gomco','Plastibell','Mogen','Freehand','Shang Ring','Smart Klamp','Circumplast','PrePex','Other'];
  safe.profile = {
    goalCI: _ci(p.goalCI),
    startingCI: _ci(p.startingCI),
    startDate: (typeof p.startDate === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(p.startDate)) ? p.startDate : null,
    ageCircumcised: _age(p.ageCircumcised),
    circumStyle: CIRC_STYLES.includes(p.circumStyle) ? p.circumStyle : null,
    circumMethod: CIRC_METHODS.includes(p.circumMethod) ? p.circumMethod : null,
    fullyRestoredOn: (typeof p.fullyRestoredOn === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(p.fullyRestoredOn)) ? p.fullyRestoredOn : null,
  };
  const ttlVal = Number(raw.vanishTTL);
  safe.vanishTTL = (!isNaN(ttlVal) && ttlVal >= 7 && ttlVal <= 365) ? Math.floor(ttlVal) : null;
  safe.encryptionEnabled = raw.encryptionEnabled === true;
  // Preserve string scope values ('none'|'latest'|'all'); v1 compat: coerce bool to string
  safe.allowView  = typeof raw.allowView  === 'string' && _SCOPE_ORDER.includes(raw.allowView)  ? raw.allowView  : undefined;
  safe.allowCache = typeof raw.allowCache === 'string' && _SCOPE_ORDER.includes(raw.allowCache) ? raw.allowCache : (raw.allowCache === true ? 'latest' : 'none');
  return safe;
}

// ─── Receiving messages ───────────────────────────────────────────────────────

async function _handleMessage(contactPubKeyB64, dc, msg) {
  if (msg.type === 'journal_manifest') {
    await _handleJournalManifest(contactPubKeyB64, dc, msg);
  } else if (msg.type === 'journal_pull') {
    await _handleJournalPull(contactPubKeyB64, dc, msg);
  } else if (msg.type === 'journal_patch') {
    await _handleJournalPatch(contactPubKeyB64, dc, msg);
  } else if (msg.type === 'manifest_error') {
    // Peer couldn't process our manifest/pull -- fall back to full journal
    console.warn(`[p2p] manifest_error from ${contactPubKeyB64.slice(0,8)}…: ${msg.reason || 'unknown'}`);
    const _mePending = _manifestPending.get(contactPubKeyB64);
    if (_mePending) { clearTimeout(_mePending.timeout); _manifestPending.delete(contactPubKeyB64); }
    if (dc.readyState === 'open') await _sendMyJournal(dc, contactPubKeyB64);

  } else if (msg.type === 'journal') {
    // Full journal received -- cancel manifest fallback if pending
    const _jPending = _manifestPending.get(contactPubKeyB64);
    if (_jPending) { clearTimeout(_jPending.timeout); _manifestPending.delete(contactPubKeyB64); }

    const identity = await getIdentity();
    const contacts = getContacts();
    const contact = contacts.find(c => c.pubKeyB64 === contactPubKeyB64);
    if (!contact || !identity) return;

    try {
      const sharedKey = await deriveSharedKey(identity.privKey, contactPubKeyB64);
      const rawJournal = await decryptWithKey({ iv: msg.iv, data: msg.data }, sharedKey);
      const journal = _sanitizeJournal(rawJournal);
      if (!journal) {
        console.warn(`[p2p] malformed journal from ${contactPubKeyB64.slice(0,8)}… -- ignored`);
        toast('Received malformed journal data. Ignored.', true);
        return;
      }
      console.log(`[p2p] received journal from ${contactPubKeyB64.slice(0,8)}… (${journal.entries.length} entries, trusted:${journal.trusted})`);

      const i = contacts.findIndex(c => c.pubKeyB64 === contactPubKeyB64);
      if (i < 0) return;

      // Snapshot previous entries before update (for new-entry detection)
      const prevJournal = contacts[i].journal || _ephemeralJournals.get(contactPubKeyB64);
      const prevIds = new Set((prevJournal?.entries || []).map(e => e.id));

      contacts[i].lastSeen = new Date().toISOString();
      contacts[i].lastJournalSync = new Date().toISOString();
      const prevSenderTrusts = contacts[i].senderTrusts;
      contacts[i].senderTrusts = journal.trusted === true;
      contacts[i].journal = journal;
      if (journal.displayName) contacts[i].nickname = journal.displayName; // update placeholder with real name
      contacts[i].vanishTTL = journal.vanishTTL ?? null;
      contacts[i].peerEncryptionEnabled = journal.encryptionEnabled === true;
      // v2: allowCache/allowView are "none"|"latest"|"all"; v1 compat: bool
      const jCacheVal = typeof journal.allowCache === 'string' ? journal.allowCache : (journal.allowCache === true ? 'latest' : 'none');
      const jViewVal  = typeof journal.allowView  === 'string' ? journal.allowView  : (journal.allowView  === true ? 'latest' : jCacheVal !== 'none' ? 'latest' : 'none');
      const _prevView3 = contacts[i].senderViewScope, _prevCache3 = contacts[i].senderCacheScope;
      contacts[i].senderAllowsCache = jCacheVal !== 'none';
      contacts[i].senderCacheScope  = jCacheVal;
      contacts[i].senderViewScope   = jViewVal;
      contacts[i].vanished = false; // they synced, no longer vanished
      if (typeof journal.hideFromBuddyList === 'boolean') contacts[i].hideFromBuddyList = journal.hideFromBuddyList;
      saveContacts(contacts);
      _resendIfScopeChanged(contactPubKeyB64, _prevView3, _prevCache3);
      if (typeof renderContactsList === 'function') renderContactsList();
      if (typeof refreshPeerJournalPanel === 'function') refreshPeerJournalPanel(contactPubKeyB64);
      if (typeof _renderDashProfile === 'function') _renderDashProfile();
      if (typeof buddyRipple === 'function') buddyRipple();

      // If the peer just started trusting us and we already trust them, resend our journal
      // so they immediately see the mutual trust flag -- without waiting for our next entry save.
      if (!prevSenderTrusts && contacts[i].senderTrusts && contacts[i].trusted) {
        console.log(`[p2p] mutual trust established with ${contactPubKeyB64.slice(0,8)}… -- sending trust_ack + settings`);
        await _sendSignedTrustAck(dc, contactPubKeyB64, true);
        await _sendSettingsPush(dc, contactPubKeyB64);
      }

      // Evict cached photos: sender revoked view, entry made private, or caching disabled
      const allowedEntryIds = new Set((journal.entries || []).filter(e => e.hasImgFront).map(e => e.id));
      const cachePrefix = `c_${contactPubKeyB64.slice(0, 16)}_`;
      const mutualTrustNow = contacts[i].trusted && contacts[i].senderTrusts;
      const cacheAllowed = mutualTrustNow || contacts[i].senderAllowsCache;
      try {
        const allKeys = await idbGetAllKeys();
        const staleKeys = allKeys.filter(k => {
          if (!k.startsWith(cachePrefix)) return false;
          // If sender disabled caching entirely, ALL their IDB photos are stale
          if (!cacheAllowed) return true;
          const parts = k.slice(cachePrefix.length).split('_');
          const entryId = parts.slice(0, -1).join('_');
          return !allowedEntryIds.has(entryId);
        });
        if (staleKeys.length) {
          await Promise.all(staleKeys.map(k => idbDel(k)));
          for (const k of staleKeys) {
            if (imgCache[k]) { URL.revokeObjectURL(imgCache[k]); delete imgCache[k]; }
            _sessionOnlyImgKeys.delete(k);
          }
          console.log(`[p2p] evicted ${staleKeys.length} stale cached photo(s) for ${contactPubKeyB64.slice(0,8)}…`);
        }
        // If caching was disabled, evict in-memory photos too -- user revoked permission
        if (!cacheAllowed) {
          for (const key of Object.keys(imgCache)) {
            if (key.startsWith(cachePrefix)) {
              URL.revokeObjectURL(imgCache[key]);
              delete imgCache[key];
              _sessionOnlyImgKeys.delete(key);
            }
          }
        }
        // Re-render UI so evicted photos disappear immediately
        if (staleKeys.length || !cacheAllowed) {
          if (typeof renderEntryList === 'function') renderEntryList();
          if (typeof _renderDashProfile === 'function') _renderDashProfile();
        }
      } catch(e) { console.warn('[p2p] photo eviction error:', e); }

      // Proactively fetch photos -- all of them if trusted, just latest otherwise
      // Stagger requests to avoid flooding the rate limiter (20/min per peer)
      const sorted = [...(journal.entries || [])].sort((a, b) => b.date.localeCompare(a.date));
      const mutualTrust = contacts[i].trusted && contacts[i].senderTrusts;
      if (mutualTrust) {
        const toFetch = sorted.filter(e => e.hasImgFront);
        toFetch.forEach((e, idx) => {
          if (idx === 0) p2pRequestImage(contactPubKeyB64, e.id, 'front');
          else setTimeout(() => p2pRequestImage(contactPubKeyB64, e.id, 'front'), idx * 3500);
        });
      } else {
        const latestWithPhoto = sorted.find(e => e.hasImgFront);
        if (latestWithPhoto) p2pRequestImage(contactPubKeyB64, latestWithPhoto.id, 'front');
      }

      // Notifications
      const name = contact.nickname || 'Buddy';
      const newEntries = (journal.entries || []).filter(e => !prevIds.has(e.id));
      for (const ne of newEntries) {
        if (typeof pushNotification === 'function') {
          if (ne.ci != null || ne.hasImgFront) {
            pushNotification('journal', `${name} posted a journal update`, '', {
              tag: `entry-${ne.id}`,
              nav: { tab: 'contacts', pubKeyB64: contactPubKeyB64, entryId: ne.id },
            });
          } else if (ne.pctTugged != null || ne.avgHrsTugged != null) {
            pushNotification('weekly', `${name} logged a weekly session`, '', {
              tag: `entry-${ne.id}`,
              nav: { tab: 'contacts', pubKeyB64: contactPubKeyB64, entryId: ne.id },
            });
          }
        }
      }
      if (!_sessionSynced.has(contactPubKeyB64)) _sessionSynced.add(contactPubKeyB64);
    } catch(e) {
      console.error('Journal decrypt failed -- wrong key or corrupted data:', e);
      toast('Could not decrypt peer journal. Check identity codes.', true);
    }

  } else if (msg.type === 'hello') {
    // Mutual buddy check -- both must have added each other
    const theirPubKey = typeof msg.pubKey === 'string' ? msg.pubKey : '';
    console.log(`[p2p] hello from ${contactPubKeyB64.slice(0,8)}… (declared: ${theirPubKey.slice(0,8)}…)`);

    // Defense-in-depth: declared pubKey must match the contact this connection was opened for
    // Validate BEFORE marking relay confirmed or showing as online
    if (!theirPubKey || theirPubKey !== contactPubKeyB64) {
      console.warn(`[p2p] hello pubKey mismatch: expected ${contactPubKeyB64.slice(0,8)}… got ${theirPubKey.slice(0,8)}… -- closing`);
      dc.close();
      return;
    }
    _relayConfirmed.add(contactPubKeyB64); // relay is working -- data received
    _cancelBuddyOnlineGrace(contactPubKeyB64); // cancel any pending offline grace timer
    // Record peer protocol version for authentication enforcement
    if (typeof msg.protocolVersion === 'number' && msg.protocolVersion >= 1) {
      _peerProtoVersion.set(contactPubKeyB64, msg.protocolVersion);
    }
    // Now we know the relay is actually working -- show as online
    updateContactOnline(contactPubKeyB64, true);
    // Start relay keepalive ping for relay-mode connections
    if (peerConns.get(contactPubKeyB64)?.relay) _startRelayPing(contactPubKeyB64);

    const contacts = getContacts();
    const contact = contacts.find(c => c.pubKeyB64 === contactPubKeyB64);
    if (!contact || contact.forgottenAt) {
      // We haven't added them, or they already removed us -- reject
      _dcSend(dc, { type: 'not_buddies' });
      dc.close();
      if (!contact) toast('Someone tried to connect but you haven\'t added them as a buddy', true);
      return;
    }

    // Two-phase removal: we removed them while they were offline -- now send forget_me and tombstone
    if (contact.pendingRemoval) {
      console.log(`[p2p] ${contactPubKeyB64.slice(0,8)}… has pendingRemoval -- sending forget_me and removing`);
      await p2pSendForgetMe(contactPubKeyB64);
      p2pRemoveContact(contactPubKeyB64);
      const all = getContacts();
      saveContacts(all.map(c => c.pubKeyB64 === contactPubKeyB64 ? { pubKeyB64: contactPubKeyB64, removedAt: Date.now() } : c));
      if (typeof p2pSigRegister === 'function') p2pSigRegister();
      if (typeof renderContactsPage === 'function') renderContactsPage();
      return;
    }

    // Fresh connection -- clear stale image denials (permissions may have changed since last session)
    _clearImgDeniedForPeer(contactPubKeyB64);

    // They connected -- clear any "waiting for them to add you" state (Phase 5)
    if (contact.notBuddiesYet) {
      const _nbyIdx = contacts.findIndex(c => c.pubKeyB64 === contactPubKeyB64);
      if (_nbyIdx >= 0) { contacts[_nbyIdx].notBuddiesYet = false; saveContacts(contacts); }
      const nbyTimer = _notBuddiesRetryTimers.get(contactPubKeyB64);
      if (nbyTimer) { clearTimeout(nbyTimer); _notBuddiesRetryTimers.delete(contactPubKeyB64); }
      if (typeof renderContactsList === 'function') renderContactsList();
    }

    // Echo hello back if we haven't sent one yet -- this ensures both sides always
    // exchange journals regardless of who initiated (relay path only has initiator
    // send hello first; the echo makes the handshake symmetric).
    if (!_helloedPeers.has(contactPubKeyB64)) {
      _helloedPeers.add(contactPubKeyB64);
      const myId = await getIdentity();
      if (myId) _dcSend(dc, { type: 'hello', pubKey: myId.pubKeyB64, protocolVersion: 2 });
    }

    // Send buddy list first -- small payload, no dependencies, survives brief DC windows
    await _sendBuddyList(dc, contactPubKeyB64);

    // Send our journal (manifest-first, fallback to full journal for old clients)
    console.log(`[p2p] sending manifest to ${contactPubKeyB64.slice(0,8)}…`);
    await _sendJournalManifest(dc, contactPubKeyB64);

    // Flush any queued notes, KOTs, and gallery KOTs for this peer
    _flushNoteQueue(dc, contactPubKeyB64);
    _flushKotQueue(dc, contactPubKeyB64);
    _flushGalleryKotQueue(dc, contactPubKeyB64);

    // Flush any queued gallery revokes for this peer, then send manifests
    if (typeof galFlushRevokeQueue === 'function') galFlushRevokeQueue(contactPubKeyB64);
    if (typeof p2pSendGalleryManifests === 'function') p2pSendGalleryManifests(contactPubKeyB64);
    // Share saved comparisons with mutually trusted buddies
    if (typeof _sendComparisons === 'function') _sendComparisons(dc, contactPubKeyB64);

    // Always drain queues on hello (not gated by _helloedPeers -- fires on every reconnect)
    if (typeof _flushQuickPicExpiries === 'function') _flushQuickPicExpiries(contactPubKeyB64);
    if (typeof qcDrainQueue === 'function') qcDrainQueue(contactPubKeyB64);
    if (typeof drainChatPhotoQueue === 'function') drainChatPhotoQueue(contactPubKeyB64);

    // Push current timer status so newly-connected buddy sees it immediately
    const _connContact = getContacts().find(c => c.pubKeyB64 === contactPubKeyB64);
    if (resolvePermissionsForContact(_connContact).timerStatus && typeof getActiveTimer === 'function') {
      const active = getActiveTimer();
      const _initTodayMs = (typeof getTodayTimerTotals === 'function') ? getTodayTimerTotals() : { tugMs: 0, retMs: 0 };
      const _initDsel = (typeof _getTimerDeviceSel === 'function') ? _getTimerDeviceSel() : {};
      const _initDev = (active && !active.stopped) ? (_initDsel[active.type === 'tugging' ? 'tug' : 'ret'] || null) : null;
      const status = (active && !active.stopped)
        ? { type: active.type, startedAt: active.startedAt, id: active.id || null, device: _initDev, todayTugMs: _initTodayMs.tugMs, todayRetMs: _initTodayMs.retMs }
        : { type: null, todayTugMs: _initTodayMs.tugMs, todayRetMs: _initTodayMs.retMs };
      try { _dcSend(dc, { type: 'buddy_timer', status }); } catch(e) {}
    }

  } else if (msg.type === 'not_buddies') {
    // They haven't added us yet -- mark contact and schedule a quiet retry (Phase 5)
    const contacts = getContacts();
    const i = contacts.findIndex(c => c.pubKeyB64 === contactPubKeyB64);
    const name = i >= 0 ? (contacts[i].nickname || contacts[i].journal?.displayName || 'This buddy') : 'This buddy';
    if (i >= 0 && !contacts[i].notBuddiesYet) {
      contacts[i].notBuddiesYet = true;
      // Stamp when this started so we can tell a brief completion race (self-heals
      // in seconds) from a genuinely stuck exchange. See the toast guard below.
      contacts[i].notBuddiesSince = Date.now();
      saveContacts(contacts);
      if (typeof renderContactsList === 'function') renderContactsList();
    }
    // Grace window: stay quiet on the first occurrence (both sides finishing at
    // once produces a transient not_buddies that resolves on the next retry).
    // Only warn once it has persisted past a retry cycle, so users aren't alarmed
    // by a race that fixes itself.
    if (i >= 0 && (Date.now() - (contacts[i].notBuddiesSince || 0)) > 6 * 60 * 1000) {
      toast(`${name} hasn't added you back yet. Still trying.`, true);
    }
    dc.close();
    // Schedule a quiet retry every 5 minutes while the flag is set
    const existingTimer = _notBuddiesRetryTimers.get(contactPubKeyB64);
    if (existingTimer) clearTimeout(existingTimer);
    const retryTimer = setTimeout(() => {
      _notBuddiesRetryTimers.delete(contactPubKeyB64);
      const c = getContacts().find(x => x.pubKeyB64 === contactPubKeyB64);
      if (!c || !c.notBuddiesYet) return;
      if (_sigWs && _sigWs.readyState === WebSocket.OPEN && _myPubKey) {
        console.log(`[p2p] not_buddies retry: re-attempting hello to ${contactPubKeyB64.slice(0,8)}…`);
        _autoConnQueue.push({ role: 'initiator', pubKey: contactPubKeyB64 });
        _runAutoConnQueue();
      }
    }, 5 * 60 * 1000);
    _notBuddiesRetryTimers.set(contactPubKeyB64, retryTimer);

  } else if (msg.type === 'chat') {
    // {type:'chat', text, displayName, ts}
    const text = _sanitizeStr(msg.text, 2000);
    const displayName = _sanitizeStr(msg.displayName, 128) || 'Peer';
    if (!text) return;
    const chatMsg = { id: typeof uuid === 'function' ? uuid() : (Date.now().toString(36) + Math.random().toString(36).slice(2,6)), from: 'them', displayName, text, ts: new Date().toISOString() };
    appendChat(contactPubKeyB64, chatMsg);
    if (typeof syncAfterSave === 'function') syncAfterSave(); // sync to paired devices
    const contacts = getContacts();
    const contact = contacts.find(c => c.pubKeyB64 === contactPubKeyB64);
    if (typeof refreshChatPanel === 'function') refreshChatPanel(contactPubKeyB64);

  } else if (msg.type === 'forget_me') {
    // Peer removed us -- delete ALL their data and fully remove them from our contacts
    console.log(`[p2p] forget_me received from ${contactPubKeyB64.slice(0,8)}…`);
    // Acknowledge before teardown -- sender uses this to confirm delivery
    if (typeof msg.nonce === 'string' && msg.nonce) {
      _dcSend(dc, { type: 'forget_me_ack', nonce: msg.nonce });
    }
    let cName = 'A buddy';
    try {
      const _fmC = getContacts().find(c => c.pubKeyB64 === contactPubKeyB64);
      cName = _fmC?.nickname || _fmC?.journal?.displayName || 'A buddy';
    } catch(e) {}
    // Clear cached data first (journal, photos, chat, notes)
    try {
      if (typeof clearContactCachedData === 'function') await clearContactCachedData(contactPubKeyB64);
    } catch(e) { console.warn('[p2p] forget_me clearContactCachedData error:', e); }
    _ephemeralJournals.delete(contactPubKeyB64);
    // Replace with tombstone so device sync propagates the removal to other paired devices
    const _fmFresh = getContacts();
    const _fmFiltered = _fmFresh.map(c => c.pubKeyB64 === contactPubKeyB64
      ? { pubKeyB64: contactPubKeyB64, removedAt: Date.now() }
      : c);
    console.log(`[p2p] forget_me: tombstoned ${contactPubKeyB64.slice(0,8)}… for device sync`);
    saveContacts(_fmFiltered);
    if (typeof syncAfterSave === 'function') syncAfterSave();
    // Close the P2P connection
    const pEntry = peerConns.get(contactPubKeyB64);
    if (pEntry) {
      if (!pEntry.relay) try { pEntry.pc.close(); } catch(e) {}
      peerConns.delete(contactPubKeyB64);
    }
    _wsRelayPeers.delete(contactPubKeyB64);
    _helloedPeers.delete(contactPubKeyB64);
    _relayConfirmed.delete(contactPubKeyB64);
    if (typeof _stopRelayPing === 'function') _stopRelayPing(contactPubKeyB64);
    _clearImgDeniedForPeer(contactPubKeyB64);
    _imgReqTimes.delete(contactPubKeyB64);
    // Clear live-presence state so the removed buddy stops showing on the
    // dashboard (tugging/retaining strip reads _buddyTimerStates; the online
    // dots read _peerIdle). Without this their avatar lingered after removal.
    if (typeof _buddyTimerStates !== 'undefined') _buddyTimerStates.delete(contactPubKeyB64);
    if (typeof _peerIdle !== 'undefined') _peerIdle.delete(contactPubKeyB64);
    if (_connFailures) _connFailures.delete(contactPubKeyB64);
    if (_reconnectAttempts) _reconnectAttempts.delete(contactPubKeyB64);
    if (_sessionSynced) _sessionSynced.delete(contactPubKeyB64);
    updateContactOnline(contactPubKeyB64, false);
    updatePeerStatus();
    // Re-register with signal server so this contact is no longer in our buddy list
    if (typeof p2pSigRegister === 'function') p2pSigRegister();
    if (typeof renderContactsList === 'function') renderContactsList();
    if (typeof renderContactsPage === 'function') renderContactsPage();
    if (typeof renderDash === 'function') renderDash();
    toast(`${cName} has removed you. Their data has been erased from your device.`);

  } else if (msg.type === 'curtain') {
    // Peer's privacy curtain state changed
    const idle = !!msg.idle;
    _peerIdle.set(contactPubKeyB64, idle);
    if (idle) _buddyTimerStates.delete(contactPubKeyB64);
    updatePeerStatus();
    if (typeof renderContactsList === 'function') renderContactsList();

  } else if (msg.type === 'chat_direct') {
    await _handleChatDirect(contactPubKeyB64, msg);

  } else if (msg.type === 'chat_photo') {
    // Photo blob for a chat message -- arrives separately from the text via relay
    const _cpId = msg.id;
    const _cpPhoto = msg.photo;
    if (!_cpId || !_cpPhoto) return;
    // Check if this is a quick pic -- from the message flag, the stored chat msg, or the relay payload
    const _cpHist = typeof getChatHistory === 'function' ? getChatHistory(contactPubKeyB64) : [];
    const _cpMsg = _cpHist.find(m => m.id === _cpId);
    const _isQP = !!msg.quickpic || _cpMsg?.quickpic || (_cpMsg?.text && _cpMsg.text.startsWith('[quickpic:'));
    const chatImgKey = _isQP ? 'quickpic_' + _cpId : 'chatimg_' + _cpId;
    try {
      let b64 = _cpPhoto;
      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(chatImgKey, blob);
      if (typeof imgCache !== 'undefined') {
        if (imgCache[chatImgKey]) URL.revokeObjectURL(imgCache[chatImgKey]);
        imgCache[chatImgKey] = URL.createObjectURL(blob);
      }
      // Update existing chat message if it doesn't have an imgKey yet
      if (_cpMsg && !_cpMsg.imgKey) {
        _cpMsg.imgKey = chatImgKey;
        if (typeof saveChatHistory === 'function') saveChatHistory(contactPubKeyB64, _cpHist);
      }
      if (typeof refreshChatPopup === 'function') refreshChatPopup(contactPubKeyB64);
      if (typeof refreshMessenger === 'function') refreshMessenger(contactPubKeyB64);
    } catch(e) { console.warn('[p2p] chat_photo store error:', e); }

  } else if (msg.type === 'chat_direct_ack') {
    // Buddy confirmed they received our direct-channel message
    if (typeof markChatDelivered === 'function' && msg.id) {
      await markChatDelivered(msg.id, Date.now());
      if (typeof _chatNotify === 'function') _chatNotify(msg.id, 'delivered');
    }

  } else if (msg.type === 'buddy_timer') {
    // Live timer status from a buddy -- ephemeral, never persisted
    const perms = (typeof getBuddyPermissions === 'function') ? getBuddyPermissions() : {};
    if (!perms.showBuddyTimerStatus) return;
    const status = msg.status;
    const todayTugMs = Number(status?.todayTugMs) || 0;
    const todayRetMs = Number(status?.todayRetMs) || 0;
    if (status && typeof status.type === 'string' && ['tugging', 'retaining'].includes(status.type)) {
      const sa = Number(status.startedAt);
      if (!sa || !isFinite(sa)) return; // reject missing/invalid startedAt
      const _bsId = (typeof status.id === 'string' && status.id.length <= 64) ? status.id : null;
      const _bsDev = (typeof status.device === 'string' && status.device.length <= 40) ? status.device : null;
      _buddyTimerStates.set(contactPubKeyB64, { type: status.type, startedAt: sa, id: _bsId, device: _bsDev, todayTugMs, todayRetMs });
    } else {
      // Timer stopped -- keep today totals so the dashboard still shows hours
      const prev = _buddyTimerStates.get(contactPubKeyB64);
      const keepTug = todayTugMs || prev?.todayTugMs || 0;
      const keepRet = todayRetMs || prev?.todayRetMs || 0;
      if (keepTug || keepRet) {
        _buddyTimerStates.set(contactPubKeyB64, { type: null, startedAt: null, todayTugMs: keepTug, todayRetMs: keepRet });
      } else {
        _buddyTimerStates.delete(contactPubKeyB64);
      }
    }
    if (typeof _updateBuddyTimerBadge === 'function') _updateBuddyTimerBadge(contactPubKeyB64);

  } else if (msg.type === 'session_kot') {
    // Buddy sent a live-session KOT fist -- increment our received count for this session
    const _skSid = (typeof msg.sessionId === 'string' && msg.sessionId.length <= 64) ? msg.sessionId : null;
    if (!_skSid) return;
    if (typeof addSessionKotRcvd === 'function') addSessionKotRcvd(_skSid);
    if (typeof _renderDashProfile === 'function') _renderDashProfile();
    if (typeof _renderDashTimer === 'function') _renderDashTimer();
    const _kotContact = getContacts().find(c => c.pubKeyB64 === contactPubKeyB64);
    const _kotName = _kotContact?.nickname || _kotContact?.journal?.displayName || 'Restorer';
    const _kotTotal = typeof getSessionKotCount === 'function' ? getSessionKotCount(_skSid) : 1;
    if (typeof pushNotification === 'function') {
      pushNotification('kot', `${_kotName} sent you a KOT!`, _kotTotal > 1 ? `${_kotTotal} KOTs this session` : '', {
        tag: `session-kot-${_skSid}`,
        nav: { tab: 'dash' },
      });
    }

  } else if (msg.type === 'comparison_notify') {
    // Trusted buddy made a comparison using our photos
    const contacts = typeof getContacts === 'function' ? getContacts() : [];
    const c = contacts.find(x => x.pubKeyB64 === contactPubKeyB64);
    if (!c?.trusted || !c?.senderTrusts) return;
    const buddyName = msg.creatorName || c.journal?.displayName || c.nickname || 'Buddy';
    if (typeof pushNotification === 'function') {
      pushNotification('gallery', `${buddyName} made a comparison "${msg.name || 'Untitled'}"`, 'Using your photos', {
        tag: `cmp_notify_${Date.now()}`,
        nav: { tab: 'compare' },
      });
    }

  } else if (msg.type === 'comparisons_sync') {
    // Trusted buddy shared their saved comparisons
    const contacts = typeof getContacts === 'function' ? getContacts() : [];
    const c = contacts.find(x => x.pubKeyB64 === contactPubKeyB64);
    if (!c?.trusted || !c?.senderTrusts) return; // mutual trust required
    if (Array.isArray(msg.comparisons) && typeof _mergeComparisons === 'function') {
      _mergeComparisons(msg.comparisons, contactPubKeyB64);
    }

  } else if (msg.type === 'quickpic_expire') {
    // Buddy says these quick pics should be expired -- delete photos and mark as expired
    if (typeof _handleQuickPicExpire === 'function') _handleQuickPicExpire(contactPubKeyB64, msg.ids);

  } else if (msg.type === 'img_start') {
    const totalBytes = Number(msg.total);
    const expectedChunks = Number(msg.chunks);
    const field = typeof msg.field === 'string' ? msg.field : '';
    const entryId = typeof msg.entryId === 'string' ? msg.entryId.slice(0, 64) : '';
    const ALLOWED_MIME = ['image/jpeg', 'image/png', 'image/webp'];
    const mimeType = ALLOWED_MIME.includes(msg.mimeType) ? msg.mimeType : 'image/jpeg';
    if (totalBytes <= 0 || totalBytes > 5 * 1024 * 1024 || // max 5MB
        expectedChunks <= 0 || expectedChunks > 400 ||       // max 400 chunks @ 16KB = 6.4MB
        !entryId || !field) return;
    // Validate consistency: total bytes should not exceed chunks * CHUNK_SIZE
    if (totalBytes > expectedChunks * CHUNK_SIZE) {
      console.warn(`[p2p] img_start: inconsistent total/chunks from ${contactPubKeyB64.slice(0,8)}…`);
      return;
    }
    // Byte rate limit: max 10MB per peer per minute
    const rate = _imgBytesPerMin.get(contactPubKeyB64);
    if (rate && Date.now() < rate.resetAt && rate.bytes + totalBytes > _IMG_MAX_BYTES_PER_MIN) {
      console.warn(`[p2p] img byte rate exceeded for ${contactPubKeyB64.slice(0,8)}…`);
      _dcSend(dc, { type: 'img_denied', entryId, field, reason: 'rate_limited' });
      return;
    }
    // Evict any stale incomplete transfers (older than 30s) -- auto-retry if possible
    for (const [tk, buf] of _imgTransfer) {
      if (buf.startedAt && Date.now() - buf.startedAt > _IMG_INACTIVITY_TIMEOUT) {
        const peerKey = tk.split(':')[0];
        console.warn(`[p2p] evicting stale img transfer ${tk} (${buf.received}/${buf.expectedChunks} chunks)`);
        if (buf.inactivityTimer) clearTimeout(buf.inactivityTimer);
        _imgTransfer.delete(tk);
        try { _dcSend(dc, { type: 'img_abort', entryId: buf.entryId, field: buf.field }); } catch(e) {}
        const retryKey = `${peerKey}:${buf.entryId}:${buf.field}`;
        const req = _imgRequests.get(retryKey);
        const retries = req ? req.retries : 0;
        if (retries < _IMG_REQUEST_MAX_RETRIES && !_imgDenied.has(retryKey)) {
          _imgRequests.set(retryKey, { requestedAt: Date.now(), retries: retries + 1 });
          const peerEntry = peerConns.get(peerKey);
          if (peerEntry?.dc?.readyState === 'open') {
            console.log(`[p2p] auto-retrying image ${buf.entryId}/${buf.field} from ${peerKey.slice(0,8)}… (attempt ${retries + 1})`);
            _dcSend(peerEntry.dc, { type: 'req_img', entryId: buf.entryId, field: buf.field });
          }
        }
      }
    }
    // Count active concurrent transfers for this peer -- if at limit, drop oldest to make room
    const peerPrefix = contactPubKeyB64 + ':';
    let concurrentCount = 0;
    let oldestKey = null, oldestTime = Infinity;
    for (const [k, buf] of _imgTransfer) {
      if (k.startsWith(peerPrefix)) {
        concurrentCount++;
        if (buf.startedAt < oldestTime) { oldestTime = buf.startedAt; oldestKey = k; }
      }
    }
    if (concurrentCount >= _IMG_MAX_CONCURRENT && oldestKey) {
      // Evict the oldest active transfer to make room for new one
      const evicted = _imgTransfer.get(oldestKey);
      if (evicted?.inactivityTimer) clearTimeout(evicted.inactivityTimer);
      _imgTransfer.delete(oldestKey);
      console.log(`[p2p] evicting oldest transfer ${oldestKey} to make room`);
    } else if (concurrentCount >= _IMG_MAX_CONCURRENT) {
      return; // can't evict -- silently drop (shouldn't happen)
    }
    const sha256 = (typeof msg.sha256 === 'string' && /^[0-9a-f]{64}$/.test(msg.sha256)) ? msg.sha256 : null;
    const transferKey = `${contactPubKeyB64}:${entryId}:${field}`;
    // Set per-transfer inactivity timeout -- notify sender so they stop chunking
    const inactivityTimer = setTimeout(() => {
      const buf = _imgTransfer.get(transferKey);
      if (buf) {
        console.warn(`[p2p] img inactivity timeout for ${transferKey}`);
        _imgTransfer.delete(transferKey);
        try { _dcSend(dc, { type: 'img_abort', entryId, field }); } catch(e) {}
      }
    }, _IMG_INACTIVITY_TIMEOUT);
    _imgTransfer.set(transferKey, {
      entryId,
      field,
      mimeType,
      expectedChunks,
      totalBytes,
      sha256,
      chunks: [],
      received: 0,
      startedAt: Date.now(),
      inactivityTimer
    });

  } else if (msg.type === 'req_img') {
    if (!_checkImgRateLimit(contactPubKeyB64)) {
      console.warn(`[p2p] img rate limit hit for ${contactPubKeyB64.slice(0,8)}…`);
      _dcSend(dc, { type: 'req_img_denied', entryId: msg.entryId, field: msg.field, reason: 'rate_limited' });
      return;
    }
    await _sendImage(dc, contactPubKeyB64, msg.entryId, msg.field);

  } else if (msg.type === 'req_img_denied') {
    const denyKey = `${contactPubKeyB64}:${msg.entryId}:${msg.field}`;
    _imgDenied.add(denyKey);
    _imgRequests.delete(denyKey);
    console.log(`[p2p] image denied by ${contactPubKeyB64.slice(0,8)}…: ${msg.entryId} ${msg.field} -- ${msg.reason || 'unknown'}`);
    // Re-render UI to replace "Loading..." with unavailable state
    if (typeof renderEntryList === 'function') renderEntryList();
    if (typeof _renderDashProfile === 'function') _renderDashProfile();
    if (typeof refreshPeerJournalPanel === 'function') refreshPeerJournalPanel(contactPubKeyB64);
    if (typeof renderContactsList === 'function') renderContactsList();

  } else if (msg.type === 'img_abort') {
    // Receiver timed out waiting for chunks -- stop sending, schedule retry
    const abortKey = `${contactPubKeyB64}:${msg.entryId}:${msg.field}`;
    console.log(`[p2p] img_abort from ${contactPubKeyB64.slice(0,8)}…: ${msg.entryId} ${msg.field}`);
    // Re-request after a brief delay (the receiver may have had a transient stall)
    const req = _imgRequests.get(abortKey);
    const retries = req ? req.retries : 0;
    if (retries < _IMG_REQUEST_MAX_RETRIES && !_imgDenied.has(abortKey)) {
      setTimeout(() => {
        const peerEntry = peerConns.get(contactPubKeyB64);
        if (peerEntry?.dc?.readyState === 'open') {
          _imgRequests.set(abortKey, { requestedAt: Date.now(), retries: retries + 1 });
          _dcSend(peerEntry.dc, { type: 'req_img', entryId: msg.entryId, field: msg.field });
        }
      }, 3000 * (retries + 1));
    }

  } else if (msg.type === 'img_denied') {
    // Gallery image send was denied by sender -- unblock the gallery request queue
    const denyField = typeof msg.field === 'string' ? msg.field : '';
    if (denyField.startsWith('gal_')) {
      const deniedItemId = denyField.slice(4);
      if (typeof _galImgRequestComplete === 'function') _galImgRequestComplete(contactPubKeyB64, deniedItemId);
    }

  } else if (msg.type === 'gallery_req_img') {
    // Peer wants a gallery image they're entitled to
    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) {
      await _sendGalleryImage(dc, contactPubKeyB64, galleryId, itemId);
    }

  } else if (msg.type === 'gallery_manifest') {
    if (typeof p2pHandleGalleryManifest === 'function') {
      p2pHandleGalleryManifest(contactPubKeyB64, msg);
    }

  } else if (msg.type === 'gallery_revoke') {
    const galleryId = typeof msg.galleryId === 'string' ? msg.galleryId.slice(0, 64) : null;
    const itemIds   = Array.isArray(msg.itemIds) ? msg.itemIds.filter(s => typeof s === 'string').map(s => s.slice(0, 64)) : null;
    if (galleryId && typeof p2pHandleGalleryRevoke === 'function') {
      p2pHandleGalleryRevoke(contactPubKeyB64, galleryId, itemIds);
    }

  } else if (msg.type === 'gallery_bookmark') {
    // Trusted buddy bookmarked one of our gallery photos
    if (typeof p2pHandleGalleryBookmark === 'function') {
      p2pHandleGalleryBookmark(contactPubKeyB64, msg);
    }

  } else if (msg.type === 'journal_bookmark') {
    // Trusted buddy bookmarked one of our journal entries
    if (typeof p2pHandleJournalBookmark === 'function') {
      p2pHandleJournalBookmark(contactPubKeyB64, msg);
    }

  } else if (msg.type === 'checkin_notify') {
    if (typeof p2pHandleCheckIn === 'function') p2pHandleCheckIn(contactPubKeyB64, msg);

  // ── Quick Check messages ──
  } else if (msg.type === 'qc_propose') {
    if (typeof p2pHandleQcPropose === 'function') p2pHandleQcPropose(contactPubKeyB64, msg);
  } else if (msg.type === 'qc_accept') {
    if (typeof p2pHandleQcAccept === 'function') p2pHandleQcAccept(contactPubKeyB64, msg);
  } else if (msg.type === 'qc_decline') {
    if (typeof p2pHandleQcDecline === 'function') p2pHandleQcDecline(contactPubKeyB64, msg);
  } else if (msg.type === 'qc_submit') {
    if (typeof p2pHandleQcSubmit === 'function') p2pHandleQcSubmit(contactPubKeyB64, msg);
  } else if (msg.type === 'qc_photo_chunk') {
    if (typeof p2pHandleQcPhotoChunk === 'function') p2pHandleQcPhotoChunk(contactPubKeyB64, msg);
  } else if (msg.type === 'qc_cancel') {
    if (typeof p2pHandleQcCancel === 'function') p2pHandleQcCancel(contactPubKeyB64, msg);

  } else if (msg.type === 'gallery_photo_note') {
    // Recipient left a note on one of our gallery photos
    if (typeof p2pHandleGalleryPhotoNote === 'function') {
      p2pHandleGalleryPhotoNote(contactPubKeyB64, msg);
    }

  } else if (msg.type === 'gallery_kot') {
    const { galleryId, itemId } = msg;
    if (!galleryId || !itemId) return;
    const galleries = typeof getGalleries === 'function' ? getGalleries() : [];
    const gal = galleries.find(g => g.id === galleryId);
    if (!gal || !gal.items.some(i => i.id === itemId)) return;
    const senderSlice16 = contactPubKeyB64.slice(0, 16);
    const senderContact = getContacts().find(c => c.pubKeyB64 === contactPubKeyB64);
    const fromAvatar = senderContact?.journal?.avatar || null;
    const fromName = senderContact?.journal?.displayName || senderContact?.nickname || null;
    if (typeof addGalleryKot === 'function') addGalleryKot(galleryId, itemId, senderSlice16, fromAvatar, fromName);
    const buddyName = fromName || 'Buddy';
    const photoLabel = gal.items.find(i => i.id === itemId)?.title || 'a photo';
    if (typeof pushNotification === 'function') {
      pushNotification('kot', `${buddyName} KOT'd ${photoLabel} in "${gal.title}"`, '', {
        tag: `gal_kot_${galleryId}_${itemId}_${senderSlice16}`, nav: { tab: 'gallery' }
      });
    }
    if (typeof renderGallery === 'function') renderGallery();

  } else if (msg.type === 'buddy_list') {
    // Peer shared their buddy list (emoji + name + hash only)
    const buddies = Array.isArray(msg.buddies) ? msg.buddies.slice(0, 50) : [];
    const validated = buddies.filter(b =>
      typeof b.hash === 'string' && b.hash.length <= 24 &&
      (b.emoji == null || typeof b.emoji === 'string') &&
      (b.name == null || typeof b.name === 'string')
    ).map(b => ({
      hash: b.hash.slice(0, 24),
      emoji: b.emoji ? b.emoji.slice(0, 4) : null,
      name: b.name ? b.name.slice(0, 40) : null
    }));
    const contacts = getContacts();
    const i = contacts.findIndex(c => c.pubKeyB64 === contactPubKeyB64);
    if (i >= 0) {
      contacts[i].buddyList = validated;
      contacts[i].buddyListAt = Date.now();
      // Pre-compute pubKeyHash for our contacts so UI can do sync lookups
      let hashDirty = false;
      for (const mc of contacts) {
        if (mc.pubKeyB64 && !mc._pubKeyHash) {
          try { mc._pubKeyHash = await _hashRawPubKey(mc.pubKeyB64); hashDirty = true; } catch(e) {}
        }
      }
      saveContacts(contacts);
      if (typeof refreshPeerJournalPanel === 'function') refreshPeerJournalPanel(contactPubKeyB64);
    }

  } else if (msg.type === 'note') {
    const entryId = (typeof msg.entryId === 'string' && /^[A-Za-z0-9_-]{1,64}$/.test(msg.entryId)) ? msg.entryId : null;
    const content = typeof msg.content === 'string' ? msg.content.slice(0, 1000) : null;
    if (!entryId || !content) return;
    const fromAvatar = (typeof msg.fromAvatar === 'string' && msg.fromAvatar.length <= 8) ? msg.fromAvatar : '❓';
    const fromName = typeof msg.fromName === 'string' ? msg.fromName.slice(0, 64) : 'Buddy';
    const timestamp = typeof msg.timestamp === 'string' ? msg.timestamp : new Date().toISOString();
    const editedAt = typeof msg.editedAt === 'string' ? msg.editedAt : null;
    addReceivedNote(entryId, contactPubKeyB64, fromAvatar, fromName, content, timestamp, editedAt);
    if (typeof pushNotification === 'function') {
      pushNotification('note', `Comment from ${fromName}`, content.slice(0, 80), {
        tag: `note-${contactPubKeyB64}-${entryId}`,
        nav: { tab: 'journal', entryId },
        ttl: 7 * 86400 * 1000,
      });
    }
    if (typeof renderEntryViewer === 'function' && typeof _selEntryId !== 'undefined' && _selEntryId === entryId && !_selBuddyKey) {
      renderEntryViewer(entryId);
    } else if (typeof renderJournalPage === 'function' && typeof _selEntryId !== 'undefined' && !_selEntryId) {
      // No entry selected -- refresh the recent-comments panel in the detail pane
      renderJournalPage();
    }
    if (typeof renderEntryList === 'function') renderEntryList();

  } else if (msg.type === 'kot') {
    const entryId = (typeof msg.entryId === 'string' && msg.entryId.length <= 64) ? msg.entryId : null;
    if (!entryId) return;
    const c = getContacts().find(x => x.pubKeyB64 === contactPubKeyB64);
    const fromName = c?.nickname || c?.journal?.displayName || 'Buddy';
    const fromAvatar = c?.journal?.avatar || null;
    addReceivedKot(entryId, contactPubKeyB64, fromAvatar, fromName, new Date().toISOString());
    _batchKotNotify(fromName, contactPubKeyB64, entryId);
    if (typeof renderEntryViewer === 'function' && typeof _selEntryId !== 'undefined' && _selEntryId === entryId && !_selBuddyKey) {
      renderEntryViewer(entryId);
    }

  } else if (msg.type === 'trust_ack') {
    // Verify HMAC signature if present
    const _taValid = await _verifyControlMsg(msg, { trusted: msg.trusted === true, ts: msg.ts, nonce: msg.nonce }, contactPubKeyB64);
    if (!_taValid) return;
    // Peer granted or revoked trust -- update senderTrusts and activate mutual trust if applicable
    const contacts = getContacts();
    const i = contacts.findIndex(c => c.pubKeyB64 === contactPubKeyB64);
    if (i < 0) return;
    const prevSenderTrusts = contacts[i].senderTrusts;
    contacts[i].senderTrusts = msg.trusted === true;
    saveContacts(contacts);
    if (typeof renderContactsList === 'function') renderContactsList();
    if (!prevSenderTrusts && contacts[i].senderTrusts && contacts[i].trusted) {
      console.log(`[p2p] mutual trust confirmed via trust_ack from ${contactPubKeyB64.slice(0,8)}…`);
      const journal = contacts[i].journal;
      if (journal?.entries) {
        const sorted = [...journal.entries].sort((a, b) => b.date.localeCompare(a.date));
        sorted.filter(e => e.hasImgFront).forEach((e, idx) => {
          if (idx === 0) p2pRequestImage(contactPubKeyB64, e.id, 'front');
          else setTimeout(() => p2pRequestImage(contactPubKeyB64, e.id, 'front'), idx * 3500);
        });
      }
    }

  } else if (msg.type === 'settings_push') {
    // Verify HMAC signature if present
    const _spSigPayload = {
      trusted: msg.trusted, vanishTTL: msg.vanishTTL, allowView: msg.allowView,
      allowCache: msg.allowCache, hiddenProfileFields: msg.hiddenProfileFields,
      hasImgFrontMap: msg.hasImgFrontMap, hideFromBuddyList: msg.hideFromBuddyList,
      ts: msg.ts, nonce: msg.nonce
    };
    const _spValid = await _verifyControlMsg(msg, _spSigPayload, contactPubKeyB64);
    if (!_spValid) return;
    // Peer updated permissions/trust/vanish -- no full journal needed
    const contacts = getContacts();
    const i = contacts.findIndex(c => c.pubKeyB64 === contactPubKeyB64);
    if (i < 0) return;
    const prevSenderTrusts = contacts[i].senderTrusts;
    if (typeof msg.trusted === 'boolean')  contacts[i].senderTrusts   = msg.trusted;
    if (msg.vanishTTL != null)             contacts[i].vanishTTL      = Number(msg.vanishTTL);
    // v2: allowCache/allowView are "none"|"latest"|"all"; v1 compat: bool
    const spCacheVal = typeof msg.allowCache === 'string' ? msg.allowCache : (msg.allowCache === true ? 'latest' : 'none');
    const spViewVal  = typeof msg.allowView  === 'string' ? msg.allowView  : (msg.allowView  === true ? 'latest' : spCacheVal !== 'none' ? 'latest' : 'none');
    const _prevView4 = contacts[i].senderViewScope, _prevCache4 = contacts[i].senderCacheScope;
    contacts[i].senderAllowsCache = spCacheVal !== 'none';
    contacts[i].senderCacheScope  = spCacheVal;
    contacts[i].senderViewScope   = spViewVal;
    contacts[i].lastSeen = new Date().toISOString();
    if (typeof msg.hideFromBuddyList === 'boolean') contacts[i].hideFromBuddyList = msg.hideFromBuddyList;
    // Update per-entry hasImgFront flags (view scope)
    if (msg.hasImgFrontMap && typeof msg.hasImgFrontMap === 'object' && contacts[i].journal?.entries) {
      for (const entry of contacts[i].journal.entries) {
        if (entry.id in msg.hasImgFrontMap) entry.hasImgFront = !!msg.hasImgFrontMap[entry.id];
      }
    }
    saveContacts(contacts);
    _resendIfScopeChanged(contactPubKeyB64, _prevView4, _prevCache4);

    // Photo eviction -- evict cached blobs that exceed the sender's cache scope.
    // hasImgFront (view scope) defines what's visible; senderCacheScope defines what persists.
    const cachePrefix = `c_${contactPubKeyB64.slice(0, 16)}_`;
    const viewableIds = new Set((contacts[i].journal?.entries || []).filter(e => e.hasImgFront).map(e => e.id));
    let cacheableIds;
    if (spCacheVal === 'all') {
      cacheableIds = viewableIds;
    } else if (spCacheVal === 'latest') {
      const sorted = [...(contacts[i].journal?.entries || [])]
        .filter(e => e.hasImgFront).sort((a, b) => b.date.localeCompare(a.date));
      cacheableIds = sorted.length ? new Set([sorted[0].id]) : new Set();
    } else {
      cacheableIds = new Set(); // none -- evict everything
    }
    try {
      const allKeys = await idbGetAllKeys();
      const staleKeys = allKeys.filter(k => {
        if (!k.startsWith(cachePrefix)) return false;
        const parts = k.slice(cachePrefix.length).split('_');
        const entryId = parts.slice(0, -1).join('_');
        return !cacheableIds.has(entryId);
      });
      if (staleKeys.length) {
        await Promise.all(staleKeys.map(k => idbDel(k)));
        for (const k of staleKeys) {
          if (imgCache[k]) { URL.revokeObjectURL(imgCache[k]); delete imgCache[k]; }
          _sessionOnlyImgKeys.delete(k);
        }
      }
      if (spCacheVal === 'none') {
        for (const key of Object.keys(imgCache)) {
          if (key.startsWith(cachePrefix)) {
            URL.revokeObjectURL(imgCache[key]); delete imgCache[key]; _sessionOnlyImgKeys.delete(key);
          }
        }
      }
    } catch(e) { console.warn('[p2p] settings_push eviction error:', e); }

    // Proactive photo fetch
    const sorted = [...(contacts[i].journal?.entries || [])].sort((a, b) => b.date.localeCompare(a.date));
    if (!prevSenderTrusts && contacts[i].senderTrusts && contacts[i].trusted) {
      // Mutual trust newly established -- fetch all
      sorted.filter(e => e.hasImgFront).forEach((e, idx) => {
        if (idx === 0) p2pRequestImage(contactPubKeyB64, e.id, 'front');
        else setTimeout(() => p2pRequestImage(contactPubKeyB64, e.id, 'front'), idx * 3500);
      });
    } else {
      // Fetch latest viewable photo (session-only if not cache-allowed)
      const latest = sorted.find(e => e.hasImgFront);
      if (latest) p2pRequestImage(contactPubKeyB64, latest.id, 'front');
    }
    if (typeof renderContactsList === 'function') renderContactsList();
    if (typeof refreshPeerJournalPanel === 'function') refreshPeerJournalPanel(contactPubKeyB64);
    if (typeof _renderDashProfile === 'function') _renderDashProfile();

  } else if (msg.type === 'relay_ping') {
    _dcSend(dc, { type: 'relay_pong', ts: msg.ts });

  } else if (msg.type === 'relay_pong') {
    _relayPongTimes.set(contactPubKeyB64, Date.now());
    const stall = _relayPingStallTimers.get(contactPubKeyB64);
    if (stall) { clearTimeout(stall); _relayPingStallTimers.delete(contactPubKeyB64); }

  } else if (msg.type === 'forget_me_ack') {
    // Peer confirmed they processed our forget_me -- clear pendingRemoval if still present
    // (contact may already be removed by the hello handler; this is a no-op in that case)
    const nonce = typeof msg.nonce === 'string' ? msg.nonce : null;
    if (!nonce) return;
    const contacts = getContacts();
    const i = contacts.findIndex(c => c.pubKeyB64 === contactPubKeyB64 && c.pendingRemovalNonce === nonce);
    if (i >= 0) {
      console.log(`[p2p] forget_me_ack confirmed for ${contactPubKeyB64.slice(0,8)}… -- removing`);
      saveContacts(contacts.filter((_, j) => j !== i));
      if (typeof p2pSigRegister === 'function') p2pSigRegister();
      if (typeof renderContactsList === 'function') renderContactsList();
    }

  } else if (msg.type === 'data_cleared') {
    // Peer's vanish TTL elapsed -- their cached copy of our data is gone
    const contacts = getContacts();
    const i = contacts.findIndex(c => c.pubKeyB64 === contactPubKeyB64);
    if (i < 0) return;
    const name = contacts[i].journal?.displayName || contacts[i].nickname || 'A buddy';
    console.log(`[p2p] data_cleared from ${contactPubKeyB64.slice(0,8)}… -- their copy of our data expired`);
    toast(`${name}'s cached copy of your data expired on their device.`);
  }
  // 'ping' messages are silently ignored -- they exist only to keep the connection alive
}

function _getDC(pubKeyB64) {
  const webrtcDc = peerConns.get(pubKeyB64)?.dc;
  if (webrtcDc && webrtcDc.readyState === 'open') return webrtcDc;
  const relayDc = _makeRelayDc(pubKeyB64);
  if (relayDc.readyState === 'open') return relayDc;
  return null;
}

async function p2pSendNote(contactPubKeyB64, entryId, content, editedAt = null) {
  const identity = await getIdentity();
  if (!identity) return;
  const profile = (typeof getProfile === 'function') ? getProfile() : {};
  const fromAvatar = profile.avatar || null;
  const fromName = identity.displayName;
  const timestamp = new Date().toISOString();

  const dc = _getDC(contactPubKeyB64);
  if (dc && dc.readyState === 'open') {
    // Direct channel is already E2E encrypted at the transport (DTLS); the relay
    // never sees it, so the comment travels as-is over the data channel.
    const msg = { type: 'note', entryId, content, fromAvatar, fromName, timestamp };
    if (editedAt) msg.editedAt = editedAt;
    _dcSend(dc, msg);
  } else {
    // Relay fallback: encrypt the whole comment (text, sender identity, entryId)
    // so the relay only ever holds ciphertext. Falls back to the local queue if
    // encryption isn't possible, and never to plaintext-over-relay.
    const _nToHash = await _hashRawPubKey(contactPubKeyB64);
    let _nSent = false;
    if (_nToHash && identity?.privKey) {
      try {
        const _nKey = await deriveSharedKey(identity.privKey, contactPubKeyB64);
        const _nEnc = await encryptWithKey({ entryId, content, fromAvatar, fromName, timestamp, editedAt: editedAt || null }, _nKey);
        _nSent = _sigSend({ type: 'note_send', toHash: _nToHash, payload: JSON.stringify(_nEnc) });
      } catch (e) { console.warn('[p2p] note encrypt failed:', e); }
    }
    if (!_nSent) queueNote(contactPubKeyB64, entryId, content, editedAt);
  }
}

// Batch KOT notifications: accumulate KOTs from the same sender for 2s, then fire one notification.
const _kotAccumulator = new Map(); // pubKeyB64 → { timer, entryIds }
function _batchKotNotify(fromName, contactPubKeyB64, entryId) {
  if (!_kotAccumulator.has(contactPubKeyB64)) {
    _kotAccumulator.set(contactPubKeyB64, { timer: null, entryIds: new Set() });
  }
  const acc = _kotAccumulator.get(contactPubKeyB64);
  acc.entryIds.add(entryId);
  clearTimeout(acc.timer);
  acc.timer = setTimeout(() => {
    _kotAccumulator.delete(contactPubKeyB64);
    if (typeof pushNotification !== 'function') return;
    const n = acc.entryIds.size;
    const firstEntryId = [...acc.entryIds][0];
    if (n === 1) {
      pushNotification('kot', `${fromName} KOT'd your entry`, '', {
        tag: `kot-${contactPubKeyB64}-${firstEntryId}`,
        nav: { tab: 'journal', entryId: firstEntryId },
      });
    } else {
      pushNotification('kot', `${fromName} KOT'd ${n} of your entries`, '', {
        nav: { tab: 'journal', entryId: firstEntryId },
      });
    }
  }, 2000);
}

async function p2pSendKot(contactPubKeyB64, entryId) {
  // Never send if already KOT'd -- idempotent
  if (typeof getSentKot === 'function' && getSentKot(contactPubKeyB64, entryId)) return;
  if (typeof recordKotSent === 'function') recordKotSent(contactPubKeyB64, entryId);
  const dc = _getDC(contactPubKeyB64);
  if (dc && dc.readyState === 'open') {
    _dcSend(dc, { type: 'kot', entryId });
  } else {
    const _ekToHash = await _hashRawPubKey(contactPubKeyB64);
    const _ekSent = _ekToHash && _sigSend({ type: 'entry_kot_send', toHash: _ekToHash, entryId });
    if (!_ekSent && typeof queueKot === 'function') queueKot(contactPubKeyB64, entryId);
  }
}

async function _flushKotQueue(dc, contactPubKeyB64) {
  const queued = (typeof dequeueKotsFor === 'function') ? dequeueKotsFor(contactPubKeyB64) : [];
  for (const entryId of queued) {
    _dcSend(dc, { type: 'kot', entryId });
  }
}

async function _flushNoteQueue(dc, contactPubKeyB64) {
  const queued = dequeueNotesFor(contactPubKeyB64);
  if (!queued.length) return;
  const identity = await getIdentity();
  if (!identity) return;
  const profile = (typeof getProfile === 'function') ? getProfile() : {};
  const fromAvatar = profile.avatar || null;
  const fromName = identity.displayName;
  for (const n of queued) {
    const msg = { type: 'note', entryId: n.entryId, content: n.content, fromAvatar, fromName, timestamp: n.timestamp };
    if (n.editedAt) msg.editedAt = n.editedAt;
    _dcSend(dc, msg);
  }
}

function _handleBinaryChunk(contactPubKeyB64, ab) {
  // Find the active transfer for this peer (keyed by pubKeyB64:entryId:field)
  const peerPrefix = contactPubKeyB64 + ':';
  let transferKey = null, buf = null;
  for (const [k, v] of _imgTransfer) {
    if (k.startsWith(peerPrefix) && v.received < v.expectedChunks) {
      transferKey = k; buf = v; break;
    }
  }
  if (!buf) return;
  buf.chunks.push(ab);
  buf.received++;
  // Reset inactivity timer
  if (buf.inactivityTimer) clearTimeout(buf.inactivityTimer);
  buf.inactivityTimer = setTimeout(() => {
    if (_imgTransfer.has(transferKey)) {
      console.warn(`[p2p] img inactivity timeout for ${transferKey}`);
      _imgTransfer.delete(transferKey);
    }
  }, _IMG_INACTIVITY_TIMEOUT);
  // Track byte rate
  const now = Date.now();
  const rate = _imgBytesPerMin.get(contactPubKeyB64);
  if (!rate || now >= rate.resetAt) {
    _imgBytesPerMin.set(contactPubKeyB64, { bytes: ab.byteLength, resetAt: now + 60000 });
  } else {
    rate.bytes += ab.byteLength;
  }
  if (buf.received === buf.expectedChunks) {
    if (buf.inactivityTimer) clearTimeout(buf.inactivityTimer);
    _imgTransfer.delete(transferKey);
    _assembleImage(contactPubKeyB64, buf).catch(e => console.warn('[p2p] image assembly failed:', e));
  }
}

async function _assembleImage(contactPubKeyB64, buf) {
  // Clear in-flight tracking -- image arrived successfully
  const reqKey = `${contactPubKeyB64}:${buf.entryId}:${buf.field}`;
  _imgRequests.delete(reqKey);
  _imgDenied.delete(reqKey); // in case a previous attempt was denied but now succeeded

  const combined = new Uint8Array(buf.totalBytes);
  let offset = 0;
  for (const chunk of buf.chunks) {
    const arr = new Uint8Array(chunk);
    combined.set(arr, offset);
    offset += arr.byteLength;
  }

  // Verify SHA-256 integrity hash if provided
  if (buf.sha256) {
    const hashBuf = await crypto.subtle.digest('SHA-256', combined.buffer);
    const hashArr = Array.from(new Uint8Array(hashBuf));
    const actual = hashArr.map(b => b.toString(16).padStart(2, '0')).join('');
    if (actual !== buf.sha256) {
      console.warn(`[p2p] image integrity check FAILED for ${buf.entryId}/${buf.field} from ${contactPubKeyB64.slice(0,8)} -- expected ${buf.sha256.slice(0,16)}… got ${actual.slice(0,16)}…`);
      return; // reject corrupted/tampered image
    }
  }

  // Validate image magic bytes match declared MIME type
  const _magicOk = (combined[0] === 0xFF && combined[1] === 0xD8 && combined[2] === 0xFF && buf.mimeType === 'image/jpeg') ||
    (combined[0] === 0x89 && combined[1] === 0x50 && combined[2] === 0x4E && combined[3] === 0x47 && buf.mimeType === 'image/png') ||
    (combined[0] === 0x52 && combined[1] === 0x49 && combined[2] === 0x46 && combined[3] === 0x46 &&
     combined[8] === 0x57 && combined[9] === 0x45 && combined[10] === 0x42 && combined[11] === 0x50 && buf.mimeType === 'image/webp');
  if (!_magicOk) {
    console.warn(`[p2p] image magic bytes don't match MIME ${buf.mimeType} from ${contactPubKeyB64.slice(0,8)} -- rejecting`);
    return;
  }

  const blob = new Blob([combined], { type: buf.mimeType });

  // Gallery images use a different key prefix and routing
  if (buf.field.startsWith('gal_')) {
    const itemId    = buf.field.slice(4);
    const galIdbKey = `gal_${contactPubKeyB64.slice(0, 16)}_${buf.entryId}_${itemId}`;
    imgCache[galIdbKey] = URL.createObjectURL(blob);
    await idbPut(galIdbKey, blob); // always persist gallery images (sender chose to share)
    if (typeof p2pGalleryImageReceived === 'function') {
      p2pGalleryImageReceived(contactPubKeyB64, buf.entryId, itemId, galIdbKey);
    }
    if (typeof rerenderPhotoCanvases === 'function') rerenderPhotoCanvases(galIdbKey);
    return;
  }

  const idbKey = `c_${contactPubKeyB64.slice(0, 16)}_${buf.entryId}_${buf.field}`;

  const contact = getContacts().find(c => c.pubKeyB64 === contactPubKeyB64);
  const journal = contact?.journal || _ephemeralJournals.get(contactPubKeyB64);
  const sorted = (journal?.entries || []).sort((a, b) => b.date.localeCompare(a.date));
  const hasImgKey = `hasImg${buf.field.charAt(0).toUpperCase() + buf.field.slice(1)}`; // e.g. hasImgFront
  const newestEntry = sorted.find(e => e[hasImgKey]);
  const isNewest = !newestEntry || newestEntry.id === buf.entryId;

  // Always put in RAM for current session; assume session-only until proven cached below
  imgCache[idbKey] = URL.createObjectURL(blob);
  _sessionOnlyImgKeys.add(idbKey);

  const mutualTrust = !!(contact?.trusted && contact?.senderTrusts);
  const senderAllowsCache = !!contact?.senderAllowsCache;
  const prefix = `c_${contactPubKeyB64.slice(0, 16)}_`;
  // Check receiver's "do not cache" setting -- if enabled, all buddy photos stay RAM-only
  const _bPermsCache = typeof getBuddyPermissions === 'function' ? getBuddyPermissions() : {};
  const noCacheIncoming = !!_bPermsCache.noCacheIncoming;

  if (!noCacheIncoming) {
    if (mutualTrust) {
      // Trusted buddy: cache all photos
      await idbPut(idbKey, blob);
      _sessionOnlyImgKeys.delete(idbKey);
    } else if (isNewest && senderAllowsCache) {
      // Sender allows caching: persist only newest, evict older
      const allKeys = await idbGetAllKeys();
      const fieldSuffix = `_${buf.field}`;
      const oldKeys = allKeys.filter(k => k.startsWith(prefix) && k.endsWith(fieldSuffix) && k !== idbKey);
      await Promise.all(oldKeys.map(k => idbDel(k)));
      await idbPut(idbKey, blob);
      _sessionOnlyImgKeys.delete(idbKey);
    }
  }
  // If noCacheIncoming or no cache permission: RAM-only -- evicted on peer disconnect

  if (typeof rerenderPhotoCanvases === 'function') rerenderPhotoCanvases(idbKey);
  if (typeof refreshContactImages === 'function') {
    refreshContactImages(contactPubKeyB64, buf.entryId);
  }
  if (typeof renderEntryList === 'function') renderEntryList();
  if (typeof _renderDashProfile === 'function') _renderDashProfile();
}

// Bake a subtle tiled diagonal watermark into an image blob before sending.
// Uses the recipient's public key slice -- cryptographically fixed, can't be spoofed by renaming.
// skipWatermark=true skips both the forensic mark and any user display watermark.
function _watermarkBlob(blob, recipientPubKeySlice, skipWatermark) {
  if (skipWatermark) return Promise.resolve(blob);
  return new Promise(resolve => {
    const timeout = setTimeout(() => resolve(blob), 10000); // fallback: send unwatermarked
    const img = new Image();
    const url = URL.createObjectURL(blob);
    img.onload = () => {
      URL.revokeObjectURL(url);
      const canvas = document.createElement('canvas');
      const w = img.naturalWidth, h = img.naturalHeight;
      canvas.width = w; canvas.height = h;
      const ctx = canvas.getContext('2d');
      ctx.drawImage(img, 0, 0);
      const fs = Math.max(12, Math.min(20, Math.floor(w / 28)));
      const text = `shared with ${recipientPubKeySlice}`;
      ctx.font = `${fs}px monospace`;
      ctx.globalAlpha = 0.12;
      ctx.fillStyle = '#ffffff';
      ctx.strokeStyle = 'rgba(0,0,0,0.3)';
      ctx.lineWidth = 1;
      ctx.textAlign = 'center';
      ctx.textBaseline = 'middle';
      ctx.save();
      ctx.translate(w / 2, h / 2);
      ctx.rotate(-Math.PI / 6);
      const tw = ctx.measureText(text).width;
      const spacingX = tw + fs * 4;
      const spacingY = fs * 5;
      const diag = Math.sqrt(w * w + h * h);
      const cols = Math.ceil(diag / spacingX) + 1;
      const rows = Math.ceil(diag / spacingY) + 1;
      for (let r = -rows; r <= rows; r++) {
        for (let c = -cols; c <= cols; c++) {
          const x = c * spacingX;
          const y = r * spacingY;
          ctx.strokeText(text, x, y);
          ctx.fillText(text, x, y);
        }
      }
      ctx.restore();
      // Apply user's configured watermark (name/text + style) on top of forensic mark
      const _wm = (typeof getWatermarkSettings === 'function' ? getWatermarkSettings() : null) || { style: 'none' };
      if (_wm.style !== 'none' && typeof _drawWatermark === 'function') {
        const _wmText = (_wm.customText && _wm.customText.trim()) || lsGet('frj_displayname') || 'forestorers.org';
        _drawWatermark(ctx, _wmText, w, h, _wm);
      }
      canvas.toBlob(b => { clearTimeout(timeout); resolve(b || blob); }, 'image/jpeg', 0.82);
    };
    img.onerror = () => { clearTimeout(timeout); URL.revokeObjectURL(url); resolve(blob); };
    img.src = url;
  });
}

async function _sendImage(dc, contactPubKeyB64, entryId, field) {
  const fieldMap = { front: 'imgFront' };
  function _deny(reason) {
    _dcSend(dc, { type: 'req_img_denied', entryId, field, reason });
  }

  const contact = getContacts().find(c => c.pubKeyB64 === contactPubKeyB64);

  // Phase 4: If I'm encrypted and buddy hasn't set up encryption, withhold photos.
  // Their device would store my photos in plaintext -- that defeats my encryption.
  const iAmEncrypted = typeof getSessionKey === 'function' && !!getSessionKey();
  if (iAmEncrypted && !contact?.peerEncryptionEnabled) {
    console.log(`[p2p] photo withheld -- ${contactPubKeyB64.slice(0,8)}… hasn't set up encryption`);
    _deny('encryption_mismatch');
    return;
  }

  // Resolve permissions for this specific contact (trusted vs buddy tier)
  const perm = resolvePermissionsForContact(contact);
  if (!perm.viewLatest && !perm.viewAll) { _deny('permission_denied'); return; }

  // Mutual scope enforcement: cap our serving scope to match what they share with us.
  // Uses _effectivePhotoScopes which applies _mutualScope(myScope, theirScope) -- if they
  // share 'latest', we serve only 'latest' even if our raw tier says 'all'.
  const { allowView: effView } = _effectivePhotoScopes(perm, contact);
  if (effView === 'none') {
    _deny('mutual_required');
    return;
  }
  if (effView !== 'all') {
    // Effective scope is 'latest' -- only the most recent photo entry is servable
    const myEntries = getEntries()
      .filter(e => e[fieldMap[field]] && !e.imgPrivate)
      .sort((a, b) => b.date.localeCompare(a.date));
    if (!myEntries.length || myEntries[0].id !== entryId) { _deny('trust_limited'); return; }
  }

  const entry = getEntry(entryId);
  if (entry?.imgPrivate) { _deny('private'); return; }
  if (!entry) { _deny('not_found'); return; }
  const imgKey = entry[fieldMap[field]];
  if (!imgKey) { _deny('not_found'); return; }
  const blob = await idbGet(imgKey);
  if (!blob) { _deny('not_found'); return; }

  try {
    const compressed = await _compressImage(blob, 800, 0.75);
    if (!compressed) { _deny('not_found'); return; }
    const watermarked = await _watermarkBlob(compressed, contactPubKeyB64.slice(0, 20), perm.noWatermark);
    const ab = await watermarked.arrayBuffer();
    const totalChunks = Math.ceil(ab.byteLength / CHUNK_SIZE);
    // Compute SHA-256 integrity hash so receiver can verify no tampering
    const hashBuf = await crypto.subtle.digest('SHA-256', ab);
    const hashArr = Array.from(new Uint8Array(hashBuf));
    const sha256 = hashArr.map(b => b.toString(16).padStart(2, '0')).join('');
    _dcSend(dc, {
      type: 'img_start', entryId, field,
      mimeType: watermarked.type || 'image/jpeg', chunks: totalChunks, total: ab.byteLength,
      sha256
    });
    for (let i = 0; i < totalChunks; i++) {
      if (dc.readyState !== 'open') break;
      dc.send(ab.slice(i * CHUNK_SIZE, (i + 1) * CHUNK_SIZE));
      // Backpressure: if DC buffer is filling up, wait for it to drain
      if (dc.bufferedAmount > 64 * 1024) {
        await new Promise(r => {
          const check = () => {
            if (!dc || dc.readyState !== 'open') { r(); return; }
            if (dc.bufferedAmount < 16 * 1024) { r(); return; }
            setTimeout(check, 20);
          };
          setTimeout(check, 20);
        });
      } else if (i % 4 === 3) {
        await new Promise(r => setTimeout(r, 10));
      }
    }
  } catch(e) {
    console.error('Image send failed:', e);
    _deny('send_error');
  }
}

// ─── Gallery image sending ────────────────────────────────────────────────────
async function _sendGalleryImage(dc, contactPubKeyB64, galleryId, itemId) {
  function _deny(reason) {
    _dcSend(dc, { type: 'img_denied', entryId: galleryId, field: 'gal_' + itemId, reason });
  }

  // Verify the requesting contact is actually allowed access to this gallery
  const galleries = (typeof getGalleries === 'function') ? getGalleries() : [];
  const gallery   = galleries.find(g => g.id === galleryId);
  if (!gallery) { _deny('not_found'); return; }

  const contact = (typeof getContacts === 'function') ? getContacts().find(c => c.pubKeyB64 === contactPubKeyB64) : null;
  if (!contact || !(typeof galleryContactCanAccess === 'function' && galleryContactCanAccess(gallery, contact))) {
    _deny('permission_denied'); return;
  }

  const item = gallery.items.find(i => i.id === itemId);
  if (!item?.imgKey) { _deny('not_found'); return; }

  // Encryption guard: don't serve to unencrypted peer if we are encrypted
  const iAmEncrypted = typeof getSessionKey === 'function' && !!getSessionKey();
  if (iAmEncrypted && !contact?.peerEncryptionEnabled) { _deny('encryption_mismatch'); return; }

  // Byte rate limit: reuse existing img rate limit
  if (!_checkImgRateLimit(contactPubKeyB64)) { _deny('rate_limited'); return; }

  try {
    const blob = await idbGet(item.imgKey);
    if (!blob) { _deny('not_found'); return; }
    const compressed  = await _compressImage(blob, 800, 0.75);
    if (!compressed) { _deny('not_found'); return; }
    const _galPerm = resolvePermissionsForContact(contact);
    const watermarked = await _watermarkBlob(compressed, contactPubKeyB64.slice(0, 20), _galPerm.noWatermark);
    const ab          = await watermarked.arrayBuffer();
    const totalChunks = Math.ceil(ab.byteLength / CHUNK_SIZE);
    const hashBuf     = await crypto.subtle.digest('SHA-256', ab);
    const hashArr     = Array.from(new Uint8Array(hashBuf));
    const sha256      = hashArr.map(b => b.toString(16).padStart(2, '0')).join('');
    _dcSend(dc, {
      type: 'img_start', entryId: galleryId, field: 'gal_' + itemId,
      mimeType: watermarked.type || 'image/jpeg', chunks: totalChunks, total: ab.byteLength, sha256
    });
    for (let i = 0; i < totalChunks; i++) {
      if (dc.readyState !== 'open') break;
      dc.send(ab.slice(i * CHUNK_SIZE, (i + 1) * CHUNK_SIZE));
      if (dc.bufferedAmount > 64 * 1024) {
        await new Promise(r => {
          const check = () => {
            if (!dc || dc.readyState !== 'open') { r(); return; }
            if (dc.bufferedAmount < 16 * 1024) { r(); return; }
            setTimeout(check, 20);
          };
          setTimeout(check, 20);
        });
      } else if (i % 4 === 3) {
        await new Promise(r => setTimeout(r, 10));
      }
    }
  } catch(e) {
    console.error('[p2p] gallery image send failed:', e);
    _deny('send_error');
  }
}

// ─── Public API ───────────────────────────────────────────────────────────────

function p2pRequestImage(contactPubKeyB64, entryId, field) {
  const entry = peerConns.get(contactPubKeyB64);
  if (!entry?.dc || entry.dc.readyState !== 'open') return false;
  const cacheKey = `c_${contactPubKeyB64.slice(0, 16)}_${entryId}_${field}`;
  if (imgCache[cacheKey]) return true;
  // Don't re-request images that were denied this session
  const denyKey = `${contactPubKeyB64}:${entryId}:${field}`;
  if (_imgDenied.has(denyKey)) return false;
  // Mutual enforcement at request site: don't request if we have no photos to share
  const _bPermsReq = typeof getBuddyPermissions === 'function' ? getBuddyPermissions() : {};
  if (_bPermsReq.requireMutualPhoto !== false) {
    const _contact = getContacts().find(c => c.pubKeyB64 === contactPubKeyB64);
    const _perm = resolvePermissionsForContact(_contact);
    const _myView = _perm.viewAll ? 'all' : _perm.viewLatest ? 'latest' : 'none';
    if (_myView === 'none') return false; // we don't share, so we can't view
    const _weHavePhoto = getEntries().some(e => e.imgFront && !e.imgPrivate);
    if (!_weHavePhoto) return false; // we have sharing enabled but no photos -- no reciprocity
  }
  // Don't re-request if already in-flight (prevents spam from re-renders)
  const existing = _imgRequests.get(denyKey);
  if (existing && Date.now() - existing.requestedAt < _IMG_REQUEST_TIMEOUT) return false;
  // Throttle: max _IMG_MAX_CONCURRENT in-flight requests per peer
  const prefix = contactPubKeyB64 + ':';
  let inflight = 0;
  for (const [k, req] of _imgRequests) {
    if (k.startsWith(prefix) && Date.now() - req.requestedAt < _IMG_REQUEST_TIMEOUT) inflight++;
  }
  if (inflight >= _IMG_MAX_CONCURRENT) return false; // will be picked up by retry interval
  _imgRequests.set(denyKey, { requestedAt: Date.now(), retries: existing ? existing.retries : 0 });
  _dcSend(entry.dc, { type: 'req_img', entryId, field });
  return false;
}

// Check if an image was denied by the sender
function p2pIsImgDenied(contactPubKeyB64, entryId, field) {
  return _imgDenied.has(`${contactPubKeyB64}:${entryId}:${field}`);
}

// Public check: can we display this buddy's photos given current mutual state?
// Called by the display layer before showing cached buddy photos.
// When mutual fails, proactively evicts stale cached photos from imgCache.
function p2pCanViewBuddyPhotos(contactPubKeyB64) {
  const _bPermsV = typeof getBuddyPermissions === 'function' ? getBuddyPermissions() : {};
  if (_bPermsV.requireMutualPhoto === false) return true; // mutual disabled -- always show
  const _contact = getContacts().find(c => c.pubKeyB64 === contactPubKeyB64);
  if (!_contact) return false;
  const _perm = resolvePermissionsForContact(_contact);
  const _myView = _perm.viewAll ? 'all' : _perm.viewLatest ? 'latest' : 'none';
  let allowed = true;
  if (_myView === 'none') allowed = false;
  else if (!getEntries().some(e => e.imgFront && !e.imgPrivate)) allowed = false;
  else if (!_theyHaveLoadablePhoto(_contact)) allowed = false;
  // When mutual fails, evict stale cached photos so nothing displays
  if (!allowed && typeof imgCache !== 'undefined') {
    const prefix = `c_${contactPubKeyB64.slice(0, 16)}_`;
    for (const k of Object.keys(imgCache)) {
      if (k.startsWith(prefix)) {
        URL.revokeObjectURL(imgCache[k]);
        delete imgCache[k];
      }
    }
  }
  return allowed;
}

// Re-send our journal to a specific buddy -- call after changing trust setting
async function p2pResendJournal(pubKeyB64) {
  const entry = peerConns.get(pubKeyB64);
  if (!entry?.dc || entry.dc.readyState !== 'open') return;
  await _sendMyJournal(entry.dc, pubKeyB64);
}

// Broadcast our journal to all connected buddies -- call after saving/editing an entry
async function p2pBroadcastJournal() {
  for (const [pubKeyB64, entry] of peerConns) {
    if (entry?.dc?.readyState === 'open') {
      await _sendMyJournal(entry.dc, pubKeyB64);
    }
  }
}

// Relay latest journal entry to all buddies via signal server (store-and-forward).
// Buddies receive it even if offline -- server holds it for up to 48h.
async function p2pRelayJournalUpdate() {
  if (p2pIsOfflineMode() || !_myPubKey) return;
  const identity = await getIdentity();
  if (!identity?.privKey) return;
  const contacts = (typeof getContacts === 'function') ? getContacts() : [];
  const entries = (typeof getEntries === 'function') ? getEntries() : [];
  if (!entries.length) return;

  // Find the most recently updated entry
  const latest = entries.reduce((a, b) => ((b.updatedAt || b.date) > (a.updatedAt || a.date) ? b : a), entries[0]);

  const _bPerms = (typeof getBuddyPermissions === 'function') ? getBuddyPermissions() : {};
  const rawProfile = (typeof getProfile === 'function') ? getProfile() : {};

  for (const c of contacts) {
    if (!c.pubKeyB64 || c.removedAt || c.forgottenAt || c.pendingRemoval) continue;
    const perm = resolvePermissionsForContact(c);
    const { allowView: rawView } = _rawPhotoScopes(perm);
    const { allowView, allowCache } = _wirePhotoScopes(perm, c);

    try {
      const key = await deriveSharedKey(identity.privKey, c.pubKeyB64);
      const toHash = await _hashRawPubKey(c.pubKeyB64);
      if (!toHash) continue;

      // Build lightweight entry (no photo blobs, no workspace scan data)
      const relayEntry = { ...latest, imgFront: null, imgLeft: null, imgRight: null };
      if (relayEntry.wsScan) relayEntry.wsScan = { ...relayEntry.wsScan, photoKey: null, displayKey: null, edgeKey: null, overlayKey: null };
      const hasImgMap = _buildHasImgFrontMap(perm, rawView);
      relayEntry.hasImgFront = !!hasImgMap[latest.id];

      const payload = {
        entry: relayEntry,
        displayName: identity.displayName || '',
        avatar: rawProfile.avatar || null,
        trusted: !!c.trusted,
        allowView,
        allowCache
      };
      const encrypted = await encryptWithKey(payload, key);
      _sigSend({ type: 'journal_update', toHash, payload: JSON.stringify(encrypted) });

      // Also relay the photo blob(s) so async buddies see photos too. Server stores 48h.
      // Skip if DC is open (DC delivery is faster + cheaper).
      const _jrDc = _getDC(c.pubKeyB64);
      if (_jrDc && _jrDc.readyState === 'open') continue;
      // Only send photo if recipient is allowed to view it (mutual photo gate respected)
      if (!hasImgMap[latest.id]) continue;
      for (const field of ['front', 'left', 'right']) {
        const imgKey = latest['img' + field.charAt(0).toUpperCase() + field.slice(1)];
        if (!imgKey) continue;
        try {
          const blob = await idbGet(imgKey);
          if (!blob) continue;
          // Compress to relay-safe size (mirrors _sendImage)
          const compressed = await _compressImage(blob, 800, 0.75);
          if (!compressed) continue;
          const watermarked = await _watermarkBlob(compressed, c.pubKeyB64.slice(0, 20), perm.noWatermark);
          if (!watermarked) continue;
          if (watermarked.size > 480 * 1024) continue; // leave room for encryption overhead under 512KB
          const ab = await watermarked.arrayBuffer();
          const b64Data = _largeB64(ab);
          const iv = crypto.getRandomValues(new Uint8Array(12));
          const plain = new TextEncoder().encode(JSON.stringify({ data: b64Data, mimeType: 'image/jpeg' }));
          const ct = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, plain);
          const photoPayload = JSON.stringify({
            iv: toUrlSafeB64(b64FromBytes(iv)),
            data: toUrlSafeB64(_largeB64(ct))
          });
          if (photoPayload.length > 524288) continue;
          _sigSend({ type: 'journal_photo_relay', toHash, entryId: latest.id, field, payload: photoPayload });
        } catch(e) { console.warn(`[p2p] journal photo relay ${field} failed:`, e); }
      }
    } catch(e) {
      console.warn(`[p2p] journal relay to ${c.pubKeyB64.slice(0,8)}… failed:`, e);
    }
  }
}

// ── Buddy Snapshot -- push metadata + photo to signal server for 24h presence cache ──
// Buddies receive a "sign of life" even without simultaneous P2P presence.
// Everything is encrypted per-buddy (ECDH-derived AES-256-GCM). Server sees only opaque blobs.
async function p2pPushBuddySnapshot() {
  if (p2pIsOfflineMode() || !_myPubKey) return;
  const identity = await getIdentity();
  if (!identity?.privKey) return;
  const contacts = (typeof getContacts === 'function') ? getContacts() : [];
  const entries = (typeof getEntries === 'function') ? getEntries() : [];

  const rawProfile = (typeof getProfile === 'function') ? getProfile() : {};
  const _bPerms = (typeof getBuddyPermissions === 'function') ? getBuddyPermissions() : {};
  const hiddenFields = new Set(_bPerms.hiddenProfileFields || []);

  // Build shared profile (respecting hidden fields)
  const sharedProfile = {};
  for (const [k, v] of Object.entries({
    goalCI: rawProfile.goalCI ?? null,
    startingCI: rawProfile.startingCI ?? null,
    startDate: rawProfile.startDate || null,
    circumStyle: rawProfile.circumStyle || null,
    circumMethod: rawProfile.circumMethod || null,
    fullyRestoredOn: rawProfile.fullyRestoredOn || null,
  })) {
    sharedProfile[k] = hiddenFields.has(k) ? null : v;
  }

  // Find latest entry
  const latest = entries.length
    ? entries.reduce((a, b) => ((b.updatedAt || b.date) > (a.updatedAt || a.date) ? b : a), entries[0])
    : null;

  // Timer status
  const timer = (typeof getActiveTimer === 'function') ? getActiveTimer() : null;

  for (const c of contacts) {
    if (!c.pubKeyB64 || c.removedAt || c.forgottenAt || c.pendingRemoval) continue;
    const perm = resolvePermissionsForContact(c);
    const { allowView: rawView } = _rawPhotoScopes(perm);
    const { allowView, allowCache } = _wirePhotoScopes(perm, c);

    try {
      const key = await deriveSharedKey(identity.privKey, c.pubKeyB64);
      const toHash = await _hashRawPubKey(c.pubKeyB64);
      if (!toHash) continue;

      // Build latest entry summary (no photo blobs)
      let latestEntry = null;
      if (latest) {
        const hasImgMap = _buildHasImgFrontMap(perm, rawView);
        latestEntry = {
          id: latest.id, date: latest.date,
          ci: latest.ci ?? null,
          pctTugged: latest.pctTugged ?? null,
          pctRetained: latest.pctRetained ?? null,
          avgHrsTugged: latest.avgHrsTugged ?? null,
          avgHrsRetained: latest.avgHrsRetained ?? null,
          tuggingDevices: latest.tuggingDevices || [],
          retainingDevices: latest.retainingDevices || [],
          hasImgFront: !!hasImgMap[latest.id]
        };
      }

      const snapshot = {
        v: 1,
        ts: Date.now(),
        displayName: identity.displayName || '',
        avatar: rawProfile.avatar || null,
        profile: sharedProfile,
        latestEntry,
        timerStatus: perm.timerStatus && timer
          ? { status: timer.type || null, startedAt: timer.startedAt || null, device: timer.device || null }
          : null,
        entryCount: entries.length,
        trusted: !!c.trusted,
        allowView,
        allowCache
      };

      const encrypted = await encryptWithKey(snapshot, key);
      _sigSend({ type: 'snapshot_push', toHash, payload: JSON.stringify(encrypted) });

      // Push photo separately if buddy has viewLatest permission
      if (perm.viewLatest && latest && latest.imgFront) {
        _pushSnapshotPhoto(c, identity, key, toHash, latest, perm);
      }
    } catch(e) {
      console.warn(`[p2p] snapshot push to ${c.pubKeyB64.slice(0,8)}… failed:`, e);
    }
  }
}

async function _pushSnapshotPhoto(contact, identity, sharedKey, toHash, entry, perm) {
  try {
    const blob = await idbGet(entry.imgFront);
    if (!blob) return;
    const compressed = await _compressImage(blob, 400, 0.60);
    if (!compressed) return;
    const watermarked = await _watermarkBlob(compressed, contact.pubKeyB64.slice(0, 20), perm.noWatermark);
    const ab = await watermarked.arrayBuffer();
    const b64Data = _largeB64(ab);
    // Encrypt using raw crypto.subtle (same pattern as gallery photo relay)
    const iv = crypto.getRandomValues(new Uint8Array(12));
    const plain = new TextEncoder().encode(JSON.stringify({ data: b64Data, mimeType: 'image/jpeg', entryId: entry.id }));
    const ct = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, sharedKey, plain);
    const payload = JSON.stringify({
      iv: toUrlSafeB64(b64FromBytes(iv)),
      data: toUrlSafeB64(_largeB64(ct))
    });
    _sigSend({ type: 'snapshot_photo_push', toHash, payload });
  } catch(e) {
    console.warn(`[p2p] snapshot photo push to ${contact.pubKeyB64.slice(0,8)}… failed:`, e);
  }
}

async function p2pSendForgetMe(pubKeyB64) {
  const contact = getContacts().find(c => c.pubKeyB64 === pubKeyB64);
  const nonce = contact?.pendingRemovalNonce || null;
  const payload = { type: 'forget_me', ...(nonce ? { nonce } : {}) };

  const entry = peerConns.get(pubKeyB64);

  // Direct DC -- synchronous send, no race possible
  if (entry?.dc?.readyState === 'open' && !entry.relay) {
    _dcSend(entry.dc, payload);
    // Also send via forget_relay so it's queued if they miss the DC message
    _sendForgetRelay(pubKeyB64, payload);
    return true;
  }

  // Relay DC or offline -- use forget_relay (server queues if target is offline)
  return _sendForgetRelay(pubKeyB64, payload);
}

// Send a forget signal via the signal server's forget_relay queue.
// Unlike regular relay, the server stores this for offline peers.
async function _sendForgetRelay(pubKeyB64, payload) {
  if (!_sigWs || _sigWs.readyState !== WebSocket.OPEN) return false;
  try {
    const identity = await getIdentity();
    if (!identity?.privKey) return false;
    const sharedKey = await deriveSharedKey(identity.privKey, pubKeyB64);
    const enc = await encryptWithKey(payload, sharedKey);
    _sigWs.send(JSON.stringify({ type: 'forget_relay', to: pubKeyB64, payload: JSON.stringify(enc) }));
    return true;
  } catch(e) { console.warn('[p2p] forget_relay send failed:', e); }
  return false;
}

// On WS reconnect, retry forget_relay for any contacts stuck in pendingRemoval
async function _retryPendingForgets() {
  const contacts = getContacts();
  const pending = contacts.filter(c => c.pendingRemoval);
  if (!pending.length) return;
  for (const c of pending) {
    const sent = await p2pSendForgetMe(c.pubKeyB64);
    if (sent) {
      console.log(`[p2p] retried forget_relay for ${c.pubKeyB64.slice(0,8)}… -- sent`);
    }
  }
}

function p2pRemoveContact(pubKeyB64) {
  peerConns.delete(pubKeyB64);
  _wsRelayPeers.delete(pubKeyB64);
  _imgTransfer.delete(pubKeyB64);
  _peerIdle.delete(pubKeyB64);
  if (typeof _buddyTimerStates !== 'undefined') _buddyTimerStates.delete(pubKeyB64); // clear dashboard tugging strip
  _peerActivity.delete(pubKeyB64);
  _helloedPeers.delete(pubKeyB64);
  _relayConfirmed.delete(pubKeyB64);
  _clearImgDeniedForPeer(pubKeyB64);
  _imgReqTimes.delete(pubKeyB64);
  _connFailures.delete(pubKeyB64);
  _reconnectAttempts.delete(pubKeyB64);
  _sessionSynced.delete(pubKeyB64);
  _ephemeralJournals.delete(pubKeyB64);
  // Do NOT call saveContacts here -- caller is responsible for tombstoning
  // The old filter-and-save was destroying tombstones written by deleteBuddy
  updatePeerStatus();
}

function isOnline(pubKeyB64) {
  // During a brief WS reconnect, keep showing as online to avoid flickering
  if (_buddyOnlineGrace.has(pubKeyB64)) return true;
  const entry = peerConns.get(pubKeyB64);
  if (!entry) return false;
  // Require relay confirmation (hello round-trip) -- don't show green dot for unconfirmed relays
  return _relayConfirmed.has(pubKeyB64) && _wsRelayPeers.has(pubKeyB64) && _sigWs?.readyState === WebSocket.OPEN;
}

function isPeerIdle(pubKeyB64) {
  return !!_peerIdle.get(pubKeyB64);
}

// Get ephemeral live timer status for a buddy (null if unknown or not opted in)
function getBuddyTimerStatus(pubKeyB64) {
  return _buddyTimerStates.get(pubKeyB64) || null;
}

// Get all buddy timer states -- returns the Map directly (read-only use)
function getAllBuddyTimerStates() {
  return _buddyTimerStates;
}

// Send a live-session KOT fist to a buddy (one per session, no revoke)
async function p2pSendSessionKot(pubKeyB64, sessionId) {
  if (!sessionId) return;
  if (typeof hasGivenSessionKot === 'function' && hasGivenSessionKot(sessionId)) return; // idempotent guard
  if (typeof recordSessionKotGiven === 'function') recordSessionKotGiven(sessionId);
  const entry = peerConns.get(pubKeyB64);
  if (entry?.dc && entry.dc.readyState === 'open') {
    // P2P connected -- send directly via DataChannel
    try { _dcSend(entry.dc, { type: 'session_kot', sessionId }); } catch(e) {}
  } else {
    // Not P2P connected -- send via signal server (queues if buddy is offline)
    const _kotToHash = await _hashRawPubKey(pubKeyB64);
    if (_kotToHash) _sigSend({ type: 'kot_send', toHash: _kotToHash, sessionId });
  }
  if (typeof _renderDashProfile === 'function') _renderDashProfile();
}

// Send a gallery photo note from recipient to owner
// Returns 'sent' | 'queued' | 'failed'
async function p2pSendGalleryPhotoNote(ownerPubKey, galleryId, itemId, note) {
  const entry = peerConns.get(ownerPubKey);
  if (entry?.dc && entry.dc.readyState === 'open') {
    try { _dcSend(entry.dc, { type: 'gallery_photo_note', galleryId, itemId, note }); return 'sent'; } catch(e) {}
  }
  // Signal server fallback -- encrypt so the relay never sees the note text or ids.
  const _toHash = await _hashRawPubKey(ownerPubKey);
  if (_toHash) {
    try {
      const identity = await getIdentity();
      if (identity?.privKey) {
        const _gnKey = await deriveSharedKey(identity.privKey, ownerPubKey);
        const _gnEnc = await encryptWithKey({ galleryId, itemId, note }, _gnKey);
        if (_sigSend({ type: 'gallery_note_send', toHash: _toHash, payload: JSON.stringify(_gnEnc) })) return 'queued';
      }
    } catch (e) { console.warn('[p2p] gallery note encrypt failed:', e); }
  }
  return 'failed';
}

// Send a gallery bookmark notification from trusted recipient to owner
async function p2pSendGalleryBookmark(ownerPubKey, galleryId, itemId) {
  const dc = _getDC(ownerPubKey);
  if (dc && dc.readyState === 'open') {
    _dcSend(dc, { type: 'gallery_bookmark', galleryId, itemId });
  }
}

// Send a journal bookmark notification from trusted buddy to entry owner
async function p2pSendJournalBookmark(ownerPubKey, entryId) {
  const dc = _getDC(ownerPubKey);
  if (dc && dc.readyState === 'open') {
    _dcSend(dc, { type: 'journal_bookmark', entryId });
  }
}

// Send a gallery KOT reaction from recipient to owner
async function p2pSendGalleryKot(ownerPubKey, galleryId, itemId) {
  const ownerSlice16 = ownerPubKey.slice(0, 16);
  if (typeof getSentGalleryKot === 'function' && getSentGalleryKot(ownerSlice16, galleryId, itemId)) return;
  if (typeof recordGalleryKotSent === 'function') recordGalleryKotSent(ownerSlice16, galleryId, itemId);
  const dc = _getDC(ownerPubKey);
  if (dc && dc.readyState === 'open') {
    _dcSend(dc, { type: 'gallery_kot', galleryId, itemId });
  } else {
    const _gkToHash = await _hashRawPubKey(ownerPubKey);
    const _gkSent = _gkToHash && _sigSend({ type: 'gallery_kot_send', toHash: _gkToHash, galleryId, itemId });
    if (!_gkSent && typeof queueGalleryKot === 'function') queueGalleryKot(ownerPubKey, galleryId, itemId);
  }
}

async function _flushGalleryKotQueue(dc, contactPubKeyB64) {
  const queued = (typeof dequeueGalleryKotsFor === 'function') ? dequeueGalleryKotsFor(contactPubKeyB64) : [];
  for (const { galleryId, itemId } of queued) {
    _dcSend(dc, { type: 'gallery_kot', galleryId, itemId });
  }
}

// Broadcast our timer status to all connected buddies (if shareTimerStatus is on)
// Also sends to signal server so offline buddies see status on next connect.
async function p2pBroadcastBuddyTimerStatus(status) {
  for (const [pk, entry] of peerConns) {
    if (!isOnline(pk)) continue;
    const _contact = getContacts().find(c => c.pubKeyB64 === pk);
    if (!resolvePermissionsForContact(_contact).timerStatus) continue;
    try { _dcSend(entry.dc, { type: 'buddy_timer', status }); } catch (e) {}
  }
  // Also update signal server -- fans out to relay-only buddies and stores for offline delivery
  const _btsStatus = status?.type || null;
  const _btsStartedAt = status?.startedAt || null;
  const _btsSessionId = status?.id || null;
  const _btsDevice = (typeof status?.device === 'string' && status.device) ? status.device.slice(0, 40) : null;
  const _btsTodayTugMs = (typeof status?.todayTugMs === 'number' && status.todayTugMs >= 0) ? Math.round(status.todayTugMs) : 0;
  const _btsTodayRetMs = (typeof status?.todayRetMs === 'number' && status.todayRetMs >= 0) ? Math.round(status.todayRetMs) : 0;
  _sigSend({ type: 'timer_status_update', status: _btsStatus, startedAt: _btsStartedAt, sessionId: _btsSessionId, device: _btsDevice, todayTugMs: _btsTodayTugMs, todayRetMs: _btsTodayRetMs });
}

// ── Buddy chat ────────────────────────────────────────────────────────────────

// Called by chat UI: encrypt and send a message to a buddy.
// If they have an active DataChannel, send direct. Otherwise relay via signal server.
// Returns true if sent/queued, false on error.
async function p2pSendChatMessage(contactPubKeyB64, plaintext, messageId) {
  try {
    const identity = await getIdentity();
    if (!identity?.privKey) return false;
    const key = await deriveSharedKey(identity.privKey, contactPubKeyB64);
    const encrypted = await encryptWithKey({ text: plaintext }, key);
    const payload = JSON.stringify(encrypted);
    const toHash  = await _hashRawPubKey(contactPubKeyB64);

    // Prefer direct DataChannel if buddy is online
    const conn = peerConns.get(contactPubKeyB64);
    if (conn && isOnline(contactPubKeyB64)) {
      _dcSend(conn.dc, { type: 'chat_direct', id: messageId, payload });
      return 'direct';
    }
    // Fall back to server relay (24h queue)
    if (_sigSend({ type: 'chat_send', id: messageId, toHash, payload })) return 'queued';
    return false; // WS not connected -- caller should show failure
  } catch(e) {
    console.warn('[p2p] chat send failed:', e);
    return false;
  }
}

// Handle an incoming chat message delivered via server relay
async function _handleIncomingChatRelay(msg) {
  const { id, fromHash, payload, sentAt } = msg;
  if (!id || !fromHash || !payload) return;
  // Find the contact whose pubKey hashes to fromHash
  const contacts = (typeof getContacts === 'function') ? getContacts() : [];
  let senderPubKeyB64 = null;
  for (const c of contacts) {
    const h = await _hashRawPubKey(c.pubKeyB64);
    if (h === fromHash) { senderPubKeyB64 = c.pubKeyB64; break; }
  }
  if (!senderPubKeyB64) return; // unknown sender -- ignore
  await _decryptAndStoreChatMessage(senderPubKeyB64, id, payload, sentAt || Date.now());
}

// Handle an incoming chat message sent directly over DataChannel
async function _handleChatDirect(contactPubKeyB64, msg) {
  const { id, payload } = msg;
  if (!id || !payload) return;
  await _decryptAndStoreChatMessage(contactPubKeyB64, id, payload, Date.now());
  // Ack via DataChannel so sender knows we got it
  const conn = peerConns.get(contactPubKeyB64);
  if (conn && isOnline(contactPubKeyB64)) {
    try { _dcSend(conn.dc, { type: 'chat_direct_ack', id }); } catch(e) {}
  }
}

async function _decryptAndStoreChatMessage(contactPubKeyB64, id, payload, sentAt) {
  try {
    const identity = await getIdentity();
    if (!identity?.privKey) return;
    const key       = await deriveSharedKey(identity.privKey, contactPubKeyB64);
    const decrypted = await decryptWithKey(JSON.parse(payload), key);
    if (!decrypted?.text || typeof decrypted.text !== 'string' || decrypted.text.length > 2000) return;

    const peerSlice = contactPubKeyB64.slice(0, 16);
    const record = {
      id,
      peer:        peerSlice,
      dir:         'in',
      body:        decrypted.text,
      sentAt,
      deliveredAt: Date.now(),
      purgeAt:     sentAt + CHAT_PURGE_MS,
      failed:      false
    };
    if (typeof saveChatMessage === 'function') await saveChatMessage(record);
    if (typeof appendChat === 'function') {
      const contacts = (typeof getContacts === 'function') ? getContacts() : [];
      const c = contacts.find(x => x.pubKeyB64 === contactPubKeyB64);
      const displayName = c?.nickname || c?.journal?.displayName || 'Buddy';
      const _chatMsg = { id, from: 'them', displayName, text: decrypted.text, ts: new Date(sentAt).toISOString(), delivered: true, failed: false };
      // Detect photo reference messages
      if (decrypted.text.startsWith('[photo:') && decrypted.text.endsWith(']')) {
        _chatMsg.imgKey = decrypted.text.slice(7, -1);
      }
      // Chat photo message
      if (decrypted.text.startsWith('[chatphoto:') && decrypted.text.endsWith(']')) {
        const chatImgKey = 'chatimg_' + id;
        _chatMsg.imgKey = chatImgKey;
        if (decrypted.photo) {
          // Embedded photo -- convert base64 data URL to blob and store
          try {
            let b64 = decrypted.photo;
            if (b64.includes(',')) b64 = b64.split(',')[1]; // strip data:image/jpeg;base64, prefix
            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(chatImgKey, blob);
            if (typeof imgCache !== 'undefined') {
              if (imgCache[chatImgKey]) URL.revokeObjectURL(imgCache[chatImgKey]);
              imgCache[chatImgKey] = URL.createObjectURL(blob);
            }
          } catch(e) { console.warn('[p2p] chat photo store error:', e); }
        }
        // If no embedded photo, blob may arrive later or was too large for relay
      }
      // Quick Pic -- ephemeral photo, 1hr self-destruct
      if (decrypted.text.startsWith('[quickpic:') && decrypted.text.endsWith(']')) {
        const qpImgKey = 'quickpic_' + id;
        _chatMsg.imgKey = qpImgKey;
        _chatMsg.quickpic = true;
        _chatMsg.expiresAt = new Date(Date.now() + 3600000).toISOString();
        console.log(`[p2p] quick pic received: id=${id}, hasPhoto=${!!decrypted.photo}, key=${qpImgKey}`);
        if (decrypted.photo) {
          try {
            let b64 = decrypted.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(qpImgKey, blob);
            if (typeof imgCache !== 'undefined') {
              if (imgCache[qpImgKey]) URL.revokeObjectURL(imgCache[qpImgKey]);
              imgCache[qpImgKey] = URL.createObjectURL(blob);
            }
          } catch(e) { console.warn('[p2p] quick pic store error:', e); }
        }
      }
      appendChat(contactPubKeyB64, _chatMsg);
      if (typeof incChatUnread === 'function') incChatUnread(contactPubKeyB64);
      // Sync chat to paired devices so the other device sees it promptly
      if (typeof syncAfterSave === 'function') syncAfterSave();
    }
    if (typeof _chatNotify === 'function') _chatNotify(id, 'received', contactPubKeyB64);
  } catch(e) {
    console.warn('[p2p] chat decrypt failed:', e);
    if (typeof toast === 'function') toast('⚠ Chat receive error: ' + (e?.message || String(e)));
  }
}

// Broadcast our curtain state to all connected peers
async function p2pBroadcastCurtain(idle) {
  for (const [pk, entry] of peerConns) {
    if (!isOnline(pk)) continue;
    try { _dcSend(entry.dc, { type: 'curtain', idle }); } catch (e) {}
  }
}

function connectedCount() {
  let n = 0;
  for (const [pk] of peerConns) {
    if (_relayConfirmed.has(pk) && _wsRelayPeers.has(pk) && _sigWs?.readyState === WebSocket.OPEN) n++;
  }
  return n;
}

let _peerStatusRaf = 0;
function updatePeerStatus() {
  cancelAnimationFrame(_peerStatusRaf);
  _peerStatusRaf = requestAnimationFrame(_doUpdatePeerStatus);
}
function _doUpdatePeerStatus() {
  _peerStatusRaf = 0;
  const dot = document.getElementById('peerDot');
  const lbl = document.getElementById('peerLabel');
  if (!dot) return;
  const n = connectedCount();
  const presState = (typeof p2pGetPresenceState === 'function') ? p2pGetPresenceState() : 'offline';
  const restorers = (typeof p2pGetRestorersOnline === 'function') ? p2pGetRestorersOnline() : [];
  const directory = (typeof p2pGetDirectory === 'function') ? p2pGetDirectory() : [];
  const dirCount  = Math.max(restorers.length, directory.length);
  const publicOnly = n === 0 && presState === 'public' && dirCount > 0;

  const publicOff = presState !== 'public';
  dot.classList.toggle('ready',       n > 0);
  dot.classList.toggle('public-only', !publicOff && n === 0 && dirCount > 0);
  dot.classList.toggle('public-off',  publicOff && n === 0);

  // Top-right badge: unread messages (orange)
  const unreadLbl  = document.getElementById('peerChipLabel');
  // Bottom-right badge: online buddy count (blue)
  const onlineLbl  = document.getElementById('peerOnlineLabel');

  const totalChatUnread = (typeof getChatUnread === 'function' && typeof getContacts === 'function')
    ? getContacts().reduce((sum, c) => sum + (getChatUnread(c.pubKeyB64) || 0), 0) : 0;

  // Unread badge -- top-right, always independent of online count
  if (unreadLbl) {
    if (totalChatUnread > 0) {
      unreadLbl.textContent = totalChatUnread;
      unreadLbl.style.display = '';
    } else {
      unreadLbl.style.display = 'none';
    }
  }

  // Chats entries in the sidebar + bottom nav mirror the same unread total
  for (const el of [document.getElementById('navChatsUnread'), document.getElementById('bnChatsUnread')]) {
    if (!el) continue;
    if (totalChatUnread > 0) {
      el.textContent = totalChatUnread > 99 ? '99+' : totalChatUnread;
      el.style.display = '';
    } else {
      el.style.display = 'none';
    }
  }

  // Online count badge -- bottom-right
  if (onlineLbl) {
    if (n > 0) {
      onlineLbl.textContent = n;
      onlineLbl.style.display = '';
    } else {
      onlineLbl.style.display = 'none';
    }
  }

  // Button tooltip
  const buddyBtn = document.getElementById('buddyStatusBtn');
  if (buddyBtn) {
    if (totalChatUnread > 0 && n > 0) {
      buddyBtn.title = `${totalChatUnread} unread · ${n} online`;
    } else if (totalChatUnread > 0) {
      buddyBtn.title = `${totalChatUnread} unread message${totalChatUnread !== 1 ? 's' : ''}`;
    } else if (n > 0) {
      buddyBtn.title = `${n} buddy${n !== 1 ? 's' : ''} online`;
    } else if (!publicOff && dirCount > 0) {
      buddyBtn.title = restorers.length > 0
        ? `${restorers.length} restorer${restorers.length !== 1 ? 's' : ''} online · ${dirCount} in directory`
        : `${dirCount} restorer${dirCount !== 1 ? 's' : ''} recently active`;
    } else {
      buddyBtn.title = publicOff ? 'Public network: off' : 'Buddies: none online';
    }
  }

  // Populate buddy dropdown
  const dropdown = document.getElementById('buddyDropdown');
  if (dropdown) {
    const contacts = getContacts();
    const activeContacts = contacts.filter(c => !c.removedAt && !c.pendingRemoval);
    let buddyListHtml;
    if (!activeContacts.length) {
      buddyListHtml = `<div style="padding:10px 14px;font-family:var(--mono);font-size:11px;color:var(--muted2)">No buddies added yet</div>`;
    } else {
      // Sort: online first, then idle, then offline
      const sorted = [...activeContacts].sort((a, b) => {
        const aOnline = isOnline(a.pubKeyB64) ? (isPeerIdle(a.pubKeyB64) ? 1 : 2) : 0;
        const bOnline = isOnline(b.pubKeyB64) ? (isPeerIdle(b.pubKeyB64) ? 1 : 2) : 0;
        return bOnline - aOnline;
      });
      buddyListHtml = sorted.map(c => {
        const online = isOnline(c.pubKeyB64);
        const idle = online && isPeerIdle(c.pubKeyB64);
        const dotColor = !online ? 'var(--muted2)' : idle ? '#f97316' : 'var(--green)';
        const displayName = c.journal?.displayName || c.nickname || 'Buddy';
        const eName = (typeof esc === 'function') ? esc(displayName) : displayName.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;').replace(/'/g,'&#39;');
        const ePub  = (typeof esc === 'function') ? esc(c.pubKeyB64)  : c.pubKeyB64.replace(/'/g, '&#39;');
        const rawAvatar = c.journal?.avatar || '';
        const safeAvatar = (typeof esc === 'function') ? esc(rawAvatar) : rawAvatar.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
        const avatar = `<span style="font-size:15px;line-height:1;flex-shrink:0">${rawAvatar ? safeAvatar : '🌿'}</span>`;
        const unread = (typeof getChatUnread === 'function') ? getChatUnread(c.pubKeyB64) : 0;
        const rowBg = unread > 0 ? 'background:rgba(245,158,11,.10);' : '';
        return `<div class="li" style="padding:8px 12px;display:flex;align-items:center;gap:8px;border-bottom:1px solid var(--border2);${rowBg}">
          <span style="width:8px;height:8px;border-radius:50%;background:${dotColor};flex-shrink:0;display:inline-block"></span>
          <span style="cursor:pointer;display:flex;align-items:center;gap:8px;flex:1;min-width:0" data-action="openBuddyProfileDropdown" data-a0="${ePub}">
            ${avatar}
            <span style="font-size:12px;font-weight:500;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;${unread > 0 ? 'color:#f59e0b;font-weight:700' : ''}">${eName}</span>
          </span>
          <button data-action="openChatPopup" data-a0="${ePub}" title="Chat" style="background:none;border:none;cursor:pointer;font-size:16px;padding:5px 6px;line-height:1;${unread > 0 ? 'color:#f59e0b' : 'color:var(--muted2)'};flex-shrink:0" onclick="event.stopPropagation()">${unread > 0 ? `💬<span style="font-family:var(--mono);font-size:10px;font-weight:700;color:#f59e0b;vertical-align:top">${unread}</span>` : '💬'}</button>
        </div>`;
      }).join('');
    }
    // Preserve the Restorers Online section -- rebuild it after buddy list
    const hdrRow = `<div style="display:flex;align-items:center;padding:10px 14px 8px;border-bottom:1px solid var(--border2)">
      <span style="font-family:var(--mono);font-size:11px;font-weight:600;flex:1">Buddies</span>
      <button data-action="closeBuddyDropdown" style="background:none;border:none;cursor:pointer;font-size:16px;color:var(--muted2);padding:0 0 0 8px;line-height:1">✕</button>
    </div>`;
    dropdown.innerHTML = hdrRow + buddyListHtml +
      `<div id="buddyDropdown-restorers-online" style="display:none;border-top:1px solid var(--border2);padding:8px 0 4px">` +
        `<div style="font-family:var(--mono);font-size:11px;color:var(--muted2);padding:0 12px 6px;text-transform:uppercase;letter-spacing:.05em">Restorers Online</div>` +
        `<div id="restorers-online-row" style="padding:0 8px;display:flex;flex-wrap:wrap;gap:6px"></div>` +
      `</div>` +
      `<div id="buddyDropdown-restorers-recent" style="display:none;border-top:1px solid var(--border2);padding:8px 0 4px">` +
        `<div style="font-family:var(--mono);font-size:11px;color:var(--muted2);padding:0 12px 6px;text-transform:uppercase;letter-spacing:.05em">Recently Active</div>` +
        `<div id="restorers-recent-row" style="padding:0 8px;display:flex;flex-wrap:wrap;gap:6px"></div>` +
      `</div>`;
    // Re-populate Restorers Online row after rebuilding DOM. updateRestorersOnlineUI
    // also re-mounts the inline Buddies + Public toggles -- they would otherwise
    // have been wiped by the innerHTML reset above.
    if (typeof updateRestorersOnlineUI === 'function') updateRestorersOnlineUI();
    if (typeof _renderBuddyDropdownToggles === 'function') _renderBuddyDropdownToggles();
  }

  document.querySelectorAll('[data-peer-pub]').forEach(el => {
    const d = el.querySelector('.online-dot');
    if (!d) return;
    const pk = el.dataset.peerPub;
    const online = isOnline(pk);
    const idle = online && isPeerIdle(pk);
    d.classList.toggle('online', online && !idle);
    d.classList.toggle('idle', idle);
  });
  // Device dot + label updated asynchronously by updateDeviceStatusUI in devices.js
  if (typeof updateDeviceStatusUI === 'function') updateDeviceStatusUI();
}

function toggleBuddyDropdown() {
  const dd = document.getElementById('buddyDropdown');
  if (!dd) return;
  const open = dd.style.display !== 'none';
  if (open) {
    dd.style.display = 'none';
    _hideDropdownBackdrop();
  } else {
    dd.style.display = '';
    _showDropdownBackdrop();
    updatePeerStatus(); // refresh content when opening
  }
}

function closeBuddyDropdown() {
  const dd = document.getElementById('buddyDropdown');
  if (dd) dd.style.display = 'none';
  _hideDropdownBackdrop();
}

function updateContactOnline(pubKeyB64, online) {
  updatePeerStatus();
  if (typeof renderContactsList === 'function') renderContactsList();
  // Refresh buddy row on dashboard when someone comes/goes
  if (typeof _renderDashProfile === 'function') _renderDashProfile();
  // Peer came online -- scan for missing gallery photos and request them
  if (online && typeof _galRequestMissingFromPeer === 'function') _galRequestMissingFromPeer(pubKeyB64);
  // Go dark on any live-only views immediately when a peer drops
  if (!online) {
    // Clear stale timer badge -- ephemeral state shouldn't survive disconnect
    _buddyTimerStates.delete(pubKeyB64);
    if (typeof _onBuddyOffline === 'function') _onBuddyOffline(pubKeyB64);
  }
}

async function p2pSendChat(pubKeyB64, text) {
  const entry = peerConns.get(pubKeyB64);
  if (!entry?.dc || entry.dc.readyState !== 'open') return false;
  const identity = await getIdentity();
  _dcSend(entry.dc, { type: 'chat', text, displayName: identity?.displayName || 'Peer' });
  return true;
}


// ─── Utilities ────────────────────────────────────────────────────────────────

function _dcSend(dc, obj) {
  if (!dc || dc.readyState !== 'open') return false;
  try { dc.send(JSON.stringify(obj)); return true; } catch(e) { console.warn('dcSend failed:', e); return false; }
}

async function _compressImage(blob, maxDim, quality) {
  const url = URL.createObjectURL(blob);
  const img = new Image();
  const loaded = await Promise.race([
    new Promise(r => { img.onload = () => r(true); img.onerror = () => r(false); img.src = url; }),
    new Promise(r => setTimeout(() => r(false), 10000))
  ]);
  URL.revokeObjectURL(url);
  if (!loaded) return null;
  const scale = Math.min(1, maxDim / Math.max(img.width, img.height));
  const w = Math.round(img.width * scale), h = Math.round(img.height * scale);
  const c = document.createElement('canvas'); c.width = w; c.height = h;
  c.getContext('2d').drawImage(img, 0, 0, w, h);
  return new Promise(r => { c.toBlob(b => r(b || blob), 'image/jpeg', quality); setTimeout(() => r(blob), 5000); });
}

// Chunked base64 encoder -- avoids spread-operator RangeError in b64FromBytes for large payloads
function _largeB64(buffer) {
  const bytes = new Uint8Array(buffer);
  let bin = '';
  for (let i = 0; i < bytes.length; i += 32768)
    bin += String.fromCharCode.apply(null, bytes.subarray(i, Math.min(i + 32768, bytes.length)));
  return btoa(bin);
}
function _largeB64Decode(b64) {
  const bin = atob(b64);
  const bytes = new Uint8Array(bin.length);
  for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
  return bytes;
}

// ── Gallery manifest relay via signal server (store-and-forward) ──────────────
async function p2pRelayGalleryManifest(gallery) {
  if (p2pIsOfflineMode() || !_myPubKey) return;
  const identity = await getIdentity();
  if (!identity?.privKey) return;
  const contacts = (typeof getContacts === 'function') ? getContacts() : [];
  const items = gallery.items.map(i => ({
    id: i.id, title: i.title, note: i.note || '',
    addedAt: i.addedAt, updatedAt: i.updatedAt || i.addedAt
  }));
  const manifest = { galleryId: gallery.id, title: gallery.title, items };

  for (const c of contacts) {
    if (!c.pubKeyB64 || c.removedAt || c.forgottenAt || c.pendingRemoval) continue;
    if (typeof galleryContactCanAccess === 'function' && !galleryContactCanAccess(gallery, c)) continue;
    // Skip DC-connected peers -- they get manifests via DC
    const dc = _getDC(c.pubKeyB64);
    if (dc && dc.readyState === 'open') continue;
    try {
      const key = await deriveSharedKey(identity.privKey, c.pubKeyB64);
      const encrypted = await encryptWithKey(manifest, key);
      const toHash = await _hashRawPubKey(c.pubKeyB64);
      if (toHash) _sigSend({ type: 'gallery_manifest_relay', toHash, galleryId: gallery.id, payload: JSON.stringify(encrypted) });
    } catch(e) { console.warn('[p2p] manifest relay failed:', e); }
  }
}

// ── Gallery photo relay via signal server (store-and-forward) ─────────────────
async function p2pRelayGalleryPhoto(gallery, itemId, imgKey) {
  if (p2pIsOfflineMode() || !_myPubKey) return;
  const identity = await getIdentity();
  if (!identity?.privKey) return;
  const blob = await idbGet(imgKey);
  if (!blob) return;
  const compressed = await _compressImage(blob, 800, 0.75);
  if (!compressed) return;
  const contacts = (typeof getContacts === 'function') ? getContacts() : [];

  for (const c of contacts) {
    if (!c.pubKeyB64 || c.removedAt || c.forgottenAt || c.pendingRemoval) continue;
    if (typeof galleryContactCanAccess === 'function' && !galleryContactCanAccess(gallery, c)) continue;
    const dc = _getDC(c.pubKeyB64);
    if (dc && dc.readyState === 'open') continue; // DC handles it
    try {
      const perm = resolvePermissionsForContact(c);
      const watermarked = await _watermarkBlob(compressed, c.pubKeyB64.slice(0, 20), perm.noWatermark);
      const ab = await watermarked.arrayBuffer();
      const b64Data = _largeB64(ab);
      const key = await deriveSharedKey(identity.privKey, c.pubKeyB64);
      // Encrypt using raw crypto.subtle -- payload too large for b64FromBytes spread operator
      const iv = crypto.getRandomValues(new Uint8Array(12));
      const plain = new TextEncoder().encode(JSON.stringify({ data: b64Data, mimeType: 'image/jpeg' }));
      const ct = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, plain);
      const payload = JSON.stringify({
        iv: toUrlSafeB64(b64FromBytes(iv)),  // iv is only 12 bytes -- safe
        data: toUrlSafeB64(_largeB64(ct))
      });
      const toHash = await _hashRawPubKey(c.pubKeyB64);
      if (toHash) {
        _sigSend({ type: 'gallery_photo_relay', toHash, galleryId: gallery.id, itemId, payload });
      }
    } catch(e) { console.warn('[p2p] photo relay failed:', e); }
  }
}

// ─── Signaling client (WebSocket relay) ──────────────────────────────────────
//
// Routes encrypted buddy sync messages through forestorers-signal.onrender.com.
// Everything stays in RAM -- nothing written to disk, cleared on restart.
// The server sees only pseudonymous pubKeys -- no journal data, no names.
// Manual connect (copy/paste) still works if server is unreachable.

const SIGNAL_URL = (location.hostname === 'localhost' || location.hostname === '127.0.0.1')
  ? 'ws://localhost:8080'
  : 'wss://forestorers-signal.onrender.com';

let _deviceOnline    = false;
let _sigWs = null;
let _myPubKey = null;
let _sigReconnectDelay = 5000;

// Serial queue for relay connection attempts
const _autoConnQueue = [];
let _autoConnRunning = false;

// Track peers from whom we've received at least one hello (relay actually working)
const _relayConfirmed = new Set();

// ── Online grace period (suppress flicker during brief WS reconnects) ─────────
// When the signal WS drops and reconnects within ~5s, buddies would flicker
// offline→online. Instead we keep them visually online for a short grace window.
// If they haven't reconnected after the grace period, we mark them truly offline.
const _buddyOnlineGrace = new Map(); // pubKeyB64 → timeoutId
const _ONLINE_GRACE_MS  = 12000;    // 12s -- covers Render spin-up + reconnect + hello round-trip

function _addBuddyOnlineGrace(pubKeyB64) {
  if (_buddyOnlineGrace.has(pubKeyB64)) return; // already waiting
  const timer = setTimeout(() => {
    _buddyOnlineGrace.delete(pubKeyB64);
    // Only fire offline if they haven't reconnected in the meantime
    if (!_relayConfirmed.has(pubKeyB64)) {
      updateContactOnline(pubKeyB64, false);
      updatePeerStatus();
    }
  }, _ONLINE_GRACE_MS);
  _buddyOnlineGrace.set(pubKeyB64, timer);
}

function _cancelBuddyOnlineGrace(pubKeyB64) {
  const t = _buddyOnlineGrace.get(pubKeyB64);
  if (t) { clearTimeout(t); _buddyOnlineGrace.delete(pubKeyB64); }
}

// Periodic re-registration interval (recovers from server-side entry loss)
let _reregInterval = null;
let _pingInterval  = null;
let _imgRetryInterval = null;
// Registration promise -- resolves when server confirms registration complete
let _regReady = Promise.resolve();
let _regResolve = null;
let _sigRegisteredOnWs = false; // true after first successful registration on this WS

// DataChannel keepalive intervals
const _keepaliveIntervals   = new Map(); // pubKeyB64 → intervalId

// Relay keepalive ping state (Phase 1)
const _relayPingIntervals   = new Map(); // pubKeyB64 → intervalId
const _relayPongTimes       = new Map(); // pubKeyB64 → last pong received ts
const _relayPingStallTimers = new Map(); // pubKeyB64 → stall-detection timeoutId

// Relay health-check timers (5s initial + 30s stall)
const _healthCheckTimers = new Map(); // pubKeyB64 → outer timeoutId

// not_buddies retry state (Phase 5)
const _notBuddiesRetryTimers = new Map(); // pubKeyB64 → timeoutId

function _startRelayPing(pubKeyB64) {
  _stopRelayPing(pubKeyB64);
  function _doPing() {
    const entry = peerConns.get(pubKeyB64);
    if (!entry?.dc || entry.dc.readyState !== 'open' || !entry.relay) {
      _stopRelayPing(pubKeyB64); return;
    }
    // If encryption failed for this peer, tear down and re-establish relay connection
    if (_peerEncryptionFailed.has(pubKeyB64)) {
      console.log(`[p2p] encryption recovery: re-establishing relay for ${pubKeyB64.slice(0,8)}…`);
      _peerEncryptionFailed.delete(pubKeyB64);
      peerConns.delete(pubKeyB64);
      _wsRelayPeers.delete(pubKeyB64);
      _helloedPeers.delete(pubKeyB64);
      _relayConfirmed.delete(pubKeyB64);
      _stopRelayPing(pubKeyB64);
      // Re-queue relay connection with fresh key derivation
      _autoConnQueue.push({ role: 'initiator', pubKey: pubKeyB64 });
      _runAutoConnQueue();
      return;
    }
    const ts = Date.now();
    _dcSend(entry.dc, { type: 'relay_ping', ts });
    const stall = setTimeout(() => {
      if ((_relayPongTimes.get(pubKeyB64) || 0) >= ts) return;
      console.log(`[p2p] relay stall: ${pubKeyB64.slice(0,8)}… -- no pong in 10s, marking offline`);
      peerConns.delete(pubKeyB64);
      _wsRelayPeers.delete(pubKeyB64);
      _helloedPeers.delete(pubKeyB64);
      _relayConfirmed.delete(pubKeyB64);
      _stopRelayPing(pubKeyB64);
      updateContactOnline(pubKeyB64, false);
      updatePeerStatus();
    }, 10000);
    const prevStall = _relayPingStallTimers.get(pubKeyB64);
    if (prevStall) clearTimeout(prevStall);
    _relayPingStallTimers.set(pubKeyB64, stall);
  }
  // Front-load: first ping at 15s to catch dead relays early, then every 60s
  const firstPing = setTimeout(() => { _doPing(); }, 15000);
  _relayPingStallTimers.set(pubKeyB64 + '_first', firstPing);
  const interval = setInterval(_doPing, 60000);
  _relayPingIntervals.set(pubKeyB64, interval);
}

function _stopRelayPing(pubKeyB64) {
  const i = _relayPingIntervals.get(pubKeyB64);
  if (i) { clearInterval(i); _relayPingIntervals.delete(pubKeyB64); }
  const s = _relayPingStallTimers.get(pubKeyB64);
  if (s) { clearTimeout(s); _relayPingStallTimers.delete(pubKeyB64); }
  const f = _relayPingStallTimers.get(pubKeyB64 + '_first');
  if (f) { clearTimeout(f); _relayPingStallTimers.delete(pubKeyB64 + '_first'); }
}

// Check all non-trusted contacts: if their vanishTTL has elapsed since lastSeen, clear their data.
// Also auto-dismisses pendingRemoval entries older than 30 days (Phase 4).
async function _runVanishCheck() {
  const vanishSettings = (typeof getVanishSettings === 'function') ? getVanishSettings() : {};
  let contacts = getContacts();
  const now = Date.now();
  let anyVanished = false;
  let anyDismissed = false;

  if (vanishSettings.enabled !== false) {
    for (const c of contacts) {
      if (c.trusted || c.vanished || !c.lastSeen || !c.vanishTTL) continue;
      const ts = c.lastJournalSync || c.lastSeen;
      const daysSince = (now - new Date(ts).getTime()) / 86400000;
      if (daysSince > c.vanishTTL) {
        console.log(`[p2p] vanish: clearing stale data for ${c.pubKeyB64.slice(0,8)}… (${daysSince.toFixed(4)}d > ${c.vanishTTL}d TTL)`);
        if (typeof clearContactCachedData === 'function') await clearContactCachedData(c.pubKeyB64);
        // Courtesy signal -- tell them their cached copy expired (Phase 3)
        const entry = peerConns.get(c.pubKeyB64);
        if (entry?.dc?.readyState === 'open') {
          _dcSend(entry.dc, { type: 'data_cleared' });
        }
        anyVanished = true;
      }
    }
  }

  // Phase 4: auto-dismiss pendingRemoval entries older than 30 days
  const filtered = contacts.filter(c => {
    if (!c.pendingRemoval) return true;
    const age = c.pendingRemovalAt ? now - c.pendingRemovalAt : Infinity;
    if (age > 30 * 86400000) {
      console.log(`[p2p] auto-dismissing stale pendingRemoval for ${c.pubKeyB64.slice(0,8)}… (${Math.round(age / 86400000)}d)`);
      anyDismissed = true;
      return false; // drop from contacts
    }
    return true;
  });
  if (anyDismissed) {
    saveContacts(filtered);
    contacts = filtered;
    if (typeof p2pSigRegister === 'function') p2pSigRegister();
  }

  if (anyVanished || anyDismissed) {
    if (typeof renderContactsList === 'function') renderContactsList();
    if (typeof renderDash === 'function') renderDash();
  }
}

async function initSignaling() {
  const identity = await getIdentity();
  if (!identity) return;
  _myPubKey = identity.pubKeyB64;
  _runVanishCheck().catch(() => {}); // passive cleanup before connecting
  if (p2pIsOfflineMode()) { _updateSigDot(false); return; }
  _sigConnect();
}

function _sigConnect() {
  if (!_myPubKey) return;
  let ws;
  try {
    ws = new WebSocket(SIGNAL_URL);
  } catch(e) {
    setTimeout(_sigConnect, 30000);
    return;
  }

  ws.onopen = () => {
    console.log('[p2p] signal WS connected');
    _sigWs = ws;
    _sigReconnectDelay = 1000; // reset backoff on successful connect
    _sigRegisteredOnWs = false; // new WS -- need fresh registration
    _sigRegister();
    _updateSigDot(true);
    // Send presence heartbeat shortly after open so server updates presence
    // before the regular 60s interval fires -- enables sub-3s reconnection
    setTimeout(() => {
      if (_presenceId && _sigWs && _sigWs.readyState === WebSocket.OPEN) {
        _sigWs.send(JSON.stringify({ type: 'presence_heartbeat', presenceId: _presenceId }));
      }
    }, 500);
    // Re-apply any pending presence state (set before WS was ready)
    if (_presenceState !== 'offline') {
      const stateToApply = _presenceState;
      _presenceState = 'offline'; // reset so setPresenceState treats it as a change
      setPresenceState(stateToApply);
    }
    // Periodic re-registration: recovers from server-side entry loss (e.g. page
    // refresh race where old WS close handler deletes the new registration).
    // Also retries hello for relay peers that never confirmed data exchange.
    if (_reregInterval) clearInterval(_reregInterval);
    _reregInterval = setInterval(() => {
      if (!_sigWs || _sigWs.readyState !== WebSocket.OPEN || !_myPubKey) return;
      _sigRegister();
      // Retry hello for any relay peers that haven't exchanged data
      _wsRelayPeers.forEach(async pk => {
        if (_relayConfirmed.has(pk)) return;
        const entry = peerConns.get(pk);
        if (entry?.relay) {
          console.log(`[p2p] re-sending hello to unconfirmed relay peer ${pk.slice(0,8)}…`);
          const id = await getIdentity();
          if (id) {
            _dcSend(entry.dc, { type: 'hello', pubKey: id.pubKeyB64, protocolVersion: 2 });
            await _sendJournalManifest(entry.dc, pk);
          }
        }
      });
      // Periodic vanish check -- clear stale non-trusted buddy data mid-session
      _runVanishCheck().catch(() => {});
    }, 30000);
    // Lightweight WS ping every 22s -- keeps Render from spinning down the server
    // while the tab is open (even behind the privacy curtain).
    if (_pingInterval) clearInterval(_pingInterval);
    _pingInterval = setInterval(() => {
      if (_sigWs && _sigWs.readyState === WebSocket.OPEN) {
        _sigWs.send(JSON.stringify({ type: 'ws_ping' }));
      }
    }, 22000);
    // Dedicated image-retry timer -- fires every 10s so retries happen within
    // one timeout window (20s) rather than waiting for the 30s re-reg clock.
    if (_imgRetryInterval) clearInterval(_imgRetryInterval);
    _imgRetryInterval = setInterval(() => {
      const now = Date.now();
      for (const [rk, req] of _imgRequests) {
        if (now - req.requestedAt < _IMG_REQUEST_TIMEOUT) continue;
        if (req.retries >= _IMG_REQUEST_MAX_RETRIES) { _imgRequests.delete(rk); continue; }
        const [pk, entryId, field] = rk.split(':');
        const peerEntry = peerConns.get(pk);
        if (peerEntry?.dc?.readyState === 'open' && !_imgDenied.has(rk)) {
          console.log(`[p2p] retrying timed-out image request ${entryId}/${field} from ${pk.slice(0,8)}… (attempt ${req.retries + 1})`);
          _imgRequests.set(rk, { requestedAt: now, retries: req.retries + 1 });
          _dcSend(peerEntry.dc, { type: 'req_img', entryId, field });
        } else {
          _imgRequests.delete(rk);
        }
      }
    }, 10000);
  };

  ws.onmessage = async e => {
    let msg;
    try { msg = JSON.parse(e.data); } catch { return; }

    if (msg.type === 'ws_pong') return; // keepalive ack -- nothing to do

    if (msg.type === 'buddies_online') {
      for (const pk of (msg.pubKeys || [])) {
        const existingEntry = peerConns.get(pk);
        if (existingEntry?.relay && _wsRelayPeers.has(pk)) {
          // Already have a relay channel -- peer may have just added us; re-send hello+journal.
          console.log(`[p2p] buddies_online: existing relay peer ${pk.slice(0,8)}… -- re-sending hello+journal`);
          const id = await getIdentity();
          if (id) {
            _dcSend(existingEntry.dc, { type: 'hello', pubKey: id.pubKeyB64, protocolVersion: 2 });
            await _sendMyJournal(existingEntry.dc, pk);
          }
        } else if (!isOnline(pk)) {
          if (_myPubKey < pk) {
            _autoConnQueue.push({ role: 'initiator', pubKey: pk });
          } else {
            // Higher-key fallback: if lower-key peer doesn't connect within 4s, initiate
            setTimeout(() => {
              if (!isOnline(pk) && getContacts().some(c => c.pubKeyB64 === pk)) {
                _autoConnQueue.push({ role: 'initiator', pubKey: pk });
                _runAutoConnQueue();
              }
            }, 4000);
          }
        }
      }
      _runAutoConnQueue();

    } else if (msg.type === 'peer_online') {
      const theirKey = msg.pubKey;
      const existingEntry = peerConns.get(theirKey);
      if (existingEntry?.relay && _wsRelayPeers.has(theirKey)) {
        // Peer reconnected to signal server -- resend hello+journal in case they missed ours.
        // Their WS session restarted so they have no relay state for us; we must re-initiate.
        console.log(`[p2p] peer_online for existing relay peer ${theirKey.slice(0,8)}… -- re-sending hello+journal`);
        const id = await getIdentity();
        if (id) {
          _dcSend(existingEntry.dc, { type: 'hello', pubKey: id.pubKeyB64, protocolVersion: 2 });
          await _sendMyJournal(existingEntry.dc, theirKey);
        }
        return;
      }
      if (isOnline(theirKey)) return;
      if (_myPubKey < theirKey) {
        _autoConnQueue.push({ role: 'initiator', pubKey: theirKey });
        _runAutoConnQueue();
      } else {
        // Higher-key fallback: initiate if lower-key peer doesn't connect within 4s
        setTimeout(() => {
          if (!isOnline(theirKey) && getContacts().some(c => c.pubKeyB64 === theirKey)) {
            _autoConnQueue.push({ role: 'initiator', pubKey: theirKey });
            _runAutoConnQueue();
          }
        }, 4000);
      }

    } else if (msg.type === 'peer_offline') {
      // Signal WS dropped for this peer -- clean up relay connections
      const offPk = msg.pubKey || msg.from;
      if (offPk) {
        const pe = peerConns.get(offPk);
        if (pe?.relay) {
          // Relay DC is dead -- server-side WS is gone; use grace period to suppress flicker
          peerConns.delete(offPk);
          _wsRelayPeers.delete(offPk);
          _relayConfirmed.delete(offPk);
          _helloedPeers.delete(offPk);
          _stopRelayPing(offPk);
          _ephemeralJournals.delete(offPk);
          _buddyTimerStates.delete(offPk);
          _buddyIdleStates.delete(offPk); // they're offline, not idle -- drop the away flag
          _imgTransfer.delete(offPk);
          _evictSessionOnlyImgsForPeer(offPk);
          _addBuddyOnlineGrace(offPk); // stay visually online briefly while peer reconnects
        }
      }
      updatePeerStatus();
      if (typeof renderContactsList === 'function') renderContactsList();

    } else if (msg.type === 'challenge') {
      // Server requires proof of private-key ownership before completing registration.
      // Sign the nonce with ECDSA P-256 (same key material as our ECDH key, re-imported for signing).
      try {
        const privBytes = await idbGet('frj_keypair_priv');
        if (!privBytes || !_myPubKey) return;
        const signingKey = await crypto.subtle.importKey(
          'pkcs8', privBytes,
          { name: 'ECDSA', namedCurve: 'P-256' },
          false, ['sign']
        );
        const sigBuf = await crypto.subtle.sign(
          { name: 'ECDSA', hash: { name: 'SHA-256' } },
          signingKey,
          new TextEncoder().encode(msg.nonce)
        );
        const sig = btoa(String.fromCharCode(...new Uint8Array(sigBuf)));
        if (_sigWs && _sigWs.readyState === WebSocket.OPEN) {
          _sigWs.send(JSON.stringify({ type: 'register_verify', nonce: msg.nonce, sig }));
        }
      } catch(e) {
        console.warn('[p2p] challenge signing failed:', e);
      }

    } else if (msg.type === 'registered') {
      // Server confirmed our registration is complete -- resolve the registration promise
      _sigRegisteredOnWs = true;
      if (_regResolve) { _regResolve(); _regResolve = null; }
      // Retry forget_relay for any pendingRemoval contacts
      _retryPendingForgets();
      // Push buddy snapshot on connect so buddies see our latest state
      if (typeof p2pPushBuddySnapshot === 'function') p2pPushBuddySnapshot();
      // Re-broadcast current timer status (may have started while WS was down)
      if (typeof getActiveTimer === 'function' && typeof p2pBroadcastBuddyTimerStatus === 'function') {
        const _reconTimer = getActiveTimer();
        if (_reconTimer && _reconTimer.type) {
          // Build full status object with device + today totals (prevents flicker)
          const _dsel = typeof _getTimerDeviceSel === 'function' ? _getTimerDeviceSel() : {};
          const _reconDev = _reconTimer.type === 'tugging' ? (_dsel.tug || null) : (_dsel.ret || null);
          const _reconToday = typeof getTodayTimerTotals === 'function' ? getTodayTimerTotals() : { tugMs: 0, retMs: 0 };
          p2pBroadcastBuddyTimerStatus({
            type: _reconTimer.type,
            startedAt: _reconTimer.startedAt,
            id: _reconTimer.id,
            device: _reconDev,
            todayTugMs: _reconToday.tugMs,
            todayRetMs: _reconToday.retMs
          });
        }
      }

    } else if (msg.type === 'superseded') {
      // Another device registered the same pubKey -- normal for multi-device.
      // Stay connected (don't reconnect) so devicePeers routing remains alive
      // until device B's pair_complete intro has been processed.
      console.log('[p2p] superseded by another device -- staying connected for device routing');

    } else if (msg.type === 'peer_paired') {
      // Device B completed pairing -- device A receives this to learn device B's UUID
      if (typeof onPeerPaired === 'function') onPeerPaired(msg.deviceId, msg.label);

    } else if (msg.type === 'device_peer_online' || msg.type === 'device_peers_online') {
      // One or more paired devices just came online -- trigger a sync
      _deviceOnline = true;
      updatePeerStatus();
      const ids = msg.type === 'device_peers_online' ? (msg.deviceIds || []) : [msg.deviceId];
      ids.forEach(id => { if (typeof onDevicePeerOnline === 'function') onDevicePeerOnline(id); });

    } else if (msg.type === 'device_peer_offline') {
      if (typeof onDevicePeerOffline === 'function') onDevicePeerOffline(msg.deviceId);

    } else if (msg.type === 'device_pair_claim') {
      // Another device is re-announcing their pairing with us (lost pair_complete recovery).
      const fromId = msg.from;
      if (!fromId) return;
      const paired = typeof getPairedDevices === 'function' ? await getPairedDevices() : [];
      if (paired.some(p => p.deviceId === fromId)) {
        // Already paired -- just trigger sync (they may have missed device_peer_online)
        if (typeof onDevicePeerOnline === 'function') onDevicePeerOnline(fromId);
      } else {
        // Not yet paired on our side -- add directly (bypasses pending_pair requirement,
        // since this device has our keypair = proof it imported our pairing code)
        console.log('[p2p] device_pair_claim from', fromId.slice(0, 8), '-- completing pairing');
        paired.push({ deviceId: fromId, label: msg.label || 'Device', addedAt: new Date().toISOString(), pending: false });
        if (typeof savePairedDevices === 'function') await savePairedDevices(paired);
        if (typeof idbMetaPut === 'function') await idbMetaPut('pending_pair', null);
        _sigRegisterDevice();
        if (typeof syncInit === 'function') syncInit();
        if (typeof renderDevicesList === 'function') renderDevicesList();
        if (typeof toast === 'function') toast(`"${msg.label || 'Device'}" paired. Syncing…`);
      }

    } else if (msg.type === 'device_relayed') {
      // Encrypted device-sync payload -- hand off to sync.js
      if (typeof onDeviceMessage === 'function') onDeviceMessage(msg.from, msg.payload).catch(e => console.error('[sync] onDeviceMessage error:', e));

    } else if (msg.type === 'relayed') {
      // Encrypted payload from a buddy, relayed via WS
      const fromKey = msg.from;
      if (!fromKey || !getContacts().some(c => c.pubKeyB64 === fromKey && !c.removedAt && !c.forgottenAt)) return;

      // Register relay peer if not yet known (they activated relay first)
      if (!_wsRelayPeers.has(fromKey)) {
        _wsRelayPeers.add(fromKey);
        const relayDc = _makeRelayDc(fromKey);
        peerConns.set(fromKey, { pc: null, dc: relayDc, relay: true });
        // Don't mark as online yet -- updateContactOnline happens in hello handler
        // after confirming the relay actually works (decryption succeeds)
        updatePeerStatus();
      }
      const relayDc = peerConns.get(fromKey)?.dc;

      if (msg.kind === 'bin' && typeof msg.payload === 'string') {
        // Legacy unencrypted binary -- reject (all binary is now encrypted via 'enc' kind)
        console.warn('[p2p] rejecting unencrypted binary relay from', fromKey.slice(0, 8));
      } else if (msg.kind === 'enc' && typeof msg.payload === 'string') {
        // E2E encrypted relay message -- decrypt with ECDH shared key
        try {
          const identity = await getIdentity();
          const sharedKey = await deriveSharedKey(identity.privKey, fromKey);
          const decrypted = await decryptWithKey(JSON.parse(msg.payload), sharedKey);
          // Encrypted binary wrapper -- unwrap and route to chunk handler
          if (decrypted && decrypted._bin && typeof decrypted._bin === 'string') {
            let binStr;
            try { binStr = atob(decrypted._bin); } catch(e) {
              console.warn('[p2p] relay encrypted bin: invalid base64'); return;
            }
            const ab = new ArrayBuffer(binStr.length);
            const view = new Uint8Array(ab);
            for (let i = 0; i < binStr.length; i++) view[i] = binStr.charCodeAt(i);
            _handleBinaryChunk(fromKey, ab);
          } else {
            // Relay replay protection: verify _relayTs and _relayNonce if present
            if (decrypted._relayTs != null || decrypted._relayNonce != null) {
              const ts = decrypted._relayTs;
              const nonce = decrypted._relayNonce;
              delete decrypted._relayTs;
              delete decrypted._relayNonce;
              if (typeof ts === 'number' && Math.abs(Date.now() - ts) > _RELAY_MAX_AGE) {
                console.warn(`[p2p] relay replay rejected: ts too old from ${fromKey.slice(0,8)}…`);
                return;
              }
              if (typeof nonce === 'string') {
                const nonces = _relayNonces.get(fromKey) || [];
                if (nonces.includes(nonce)) {
                  console.warn(`[p2p] relay replay rejected: duplicate nonce from ${fromKey.slice(0,8)}…`);
                  return;
                }
                nonces.push(nonce);
                if (nonces.length > 200) nonces.shift();
                _relayNonces.set(fromKey, nonces);
              }
            }
            await _handleMessage(fromKey, relayDc, decrypted);
          }
        } catch(e) {
          console.warn('[p2p] relay decrypt failed from', fromKey.slice(0, 8), ':', e.message || e);
        }
      } else if (msg.kind === 'json' && typeof msg.payload === 'string') {
        // Legacy: try to decrypt if it looks encrypted, otherwise reject plaintext
        try {
          const parsed = JSON.parse(msg.payload);
          if (parsed && parsed.iv && (parsed.ct || parsed.data)) {
            const identity = await getIdentity();
            const sharedKey = await deriveSharedKey(identity.privKey, fromKey);
            const decrypted = await decryptWithKey(parsed, sharedKey);
            await _handleMessage(fromKey, relayDc, decrypted);
          } else {
            console.warn('[p2p] rejecting plaintext relay message from', fromKey.slice(0, 8));
          }
        } catch(e) { console.warn('[p2p] bad relayed JSON:', e); }
      }

    } else {
      // Buddy Network messages -- handled by additive dispatcher
      _handleBuddyNetMessage(msg);
    }
  };

  ws.onclose = e => {
    console.log(`[p2p] signal WS closed (code ${e.code}) -- reconnecting in ${_sigReconnectDelay}ms`);
    if (_sigWs === ws) _sigWs = null;
    // Resolve any pending registration promise so callers don't hang
    if (_regResolve) { _regResolve(); _regResolve = null; }
    // Presence heartbeat dies with the WS -- will re-register on reconnect via onopen
    if (_presenceHeartbeatInterval) { clearInterval(_presenceHeartbeatInterval); _presenceHeartbeatInterval = null; }
    _presenceId = null; // will be re-issued on reconnect
    // Reject any pending sync waiters so promises don't hang or resolve stale data
    if (typeof _clearSyncWaiters === 'function') _clearSyncWaiters();
    // WS relay peers lose their channel when the signal WS drops.
    // Use a grace period before marking offline so brief reconnects don't cause flickering.
    _wsRelayPeers.forEach(pk => {
      if (peerConns.get(pk)?.relay) peerConns.delete(pk);
      _helloedPeers.delete(pk);    // reset so reconnect does a fresh hello exchange
      _relayConfirmed.delete(pk);  // reset relay confirmation
      _clearImgDeniedForPeer(pk);  // clear denied images -- permissions may change
      _sessionSynced.delete(pk);   // allow sync notification on reconnect
      _ephemeralJournals.delete(pk); // stale in-memory journal -- will re-receive on reconnect
      _imgTransfer.delete(pk);     // discard partial image transfers to prevent memory leak
      _imgReqTimes.delete(pk);
      _evictSessionOnlyImgsForPeer(pk);
      _addBuddyOnlineGrace(pk);    // show as online briefly while WS reconnects
    });
    _wsRelayPeers.clear();
    _peerIdle.clear();
    _peerActivity.clear();
    _updateSigDot(false);
    updatePeerStatus();
    if (!p2pIsOfflineMode()) {
      // Jitter prevents thundering herd when signal server restarts
      const jitter = Math.random() * _sigReconnectDelay * 0.3;
      setTimeout(_sigConnect, _sigReconnectDelay + jitter);
      _sigReconnectDelay = Math.min(_sigReconnectDelay * 2, 60000); // 5s → 10s → 20s → 40s → 60s cap
    }
  };

  ws.onerror = e => console.warn('[p2p] signal WS error:', e);
}

function _sigRegister() {
  if (!_sigWs || _sigWs.readyState !== WebSocket.OPEN || !_myPubKey) return;
  const buddies = getContacts()
    .filter(c => !c.forgottenAt && !c.pendingRemoval && !c.removedAt)
    .map(c => c.pubKeyB64);
  if (_sigRegisteredOnWs) {
    // Already registered on this WS -- just update the buddy list
    _sigWs.send(JSON.stringify({ type: 'update_buddies', buddies }));
    // Re-register device routing too (paired list may have changed)
    _sigRegisterDevice();
    return;
  }
  _regReady = new Promise(r => {
    _regResolve = r;
    setTimeout(r, 8000); // fallback -- assume registered after 8s even without server confirmation
  });
  _sigWs.send(JSON.stringify({ type: 'register', pubKey: _myPubKey, buddies }));
  // Also register this device for device-sync routing (Sprint 2)
  _sigRegisterDevice();
}

// Register device ID alongside the buddy register -- same WS, separate routing
async function _sigRegisterDevice() {
  if (!_sigWs || _sigWs.readyState !== WebSocket.OPEN) return;
  if (typeof getDeviceId !== 'function' || typeof getPairedDevices !== 'function') return;
  try {
    const deviceId = await getDeviceId();
    const paired   = (await getPairedDevices()).map(d => d.deviceId);
    // Register even with empty paired list so pair_complete intro can find us
    _sigWs.send(JSON.stringify({ type: 'register_device', deviceId, paired }));

    // If we have a live pending pair code, register the nonce so device B can
    // route its pair_complete intro to us via the signal server
    if (typeof idbMetaGet === 'function') {
      const pending = await idbMetaGet('pending_pair');
      if (pending && pending.nonce && pending.expiresAt > Date.now()) {
        _sigWs.send(JSON.stringify({ type: 'register_pair_code', nonce: pending.nonce }));
      }
    }

    // Announce pairing to all paired devices in case pair_complete was lost when
    // the WS dropped during the original pairing window. The receiving device uses
    // this to complete its side of the pairing if it missed peer_paired.
    if (paired.length) {
      const myLabel = getDeviceLabel();
      setTimeout(() => {
        if (!_sigWs || _sigWs.readyState !== WebSocket.OPEN) return;
        for (const pid of paired) {
          _sigWs.send(JSON.stringify({ type: 'device_pair_claim', to: pid, label: myLabel }));
        }
      }, 300); // let server process register_device first
    }

    // After a WS reconnect, re-trigger sync with any paired devices (in case
    // device_peer_online was missed during the disconnect window)
    if (paired.length && typeof syncNow === 'function') {
      setTimeout(() => syncNow().catch(() => {}), 1500);
    }
  } catch(e) { console.warn('[p2p] register_device failed:', e); }
}

// Send pair_complete to device A before device B reloads (devices.js calls this)
async function _sigSendPairComplete(nonce, deviceId, label) {
  if (!_sigWs || _sigWs.readyState !== WebSocket.OPEN) return;
  _sigWs.send(JSON.stringify({ type: 'pair_complete', nonce, deviceId, label }));
}

// Send an encrypted device-sync payload to a specific paired device
function p2pSendDeviceMsg(toDeviceId, encryptedPayload) {
  if (!_sigWs || _sigWs.readyState !== WebSocket.OPEN) return false;
  _sigWs.send(JSON.stringify({ type: 'device_relay', to: toDeviceId, payload: encryptedPayload }));
  return true;
}

function p2pSigRegister() { _sigRegister(); }

// Call after key restore -- closes all connections and re-initialises with the new identity
async function p2pReinit() {
  peerConns.clear();
  _wsRelayPeers.clear();
  _imgTransfer.clear();
  _imgDenied.clear();
  _imgRequests.clear();
  _peerIdle.clear();
  _helloedPeers.clear();
  _relayConfirmed.clear();
  _ephemeralJournals.clear();
  _sessionSynced.clear();
  _connFailures.clear();
  _reconnectAttempts.clear();
  _autoConnQueue.length = 0;
  _autoConnRunning = false;

  // Close old signal WS -- onclose backoff will fire, but we cancel it by reinitialising
  if (_sigWs) {
    _sigWs.onclose = null; // suppress reconnect loop
    try { _sigWs.close(); } catch(e) {}
    _sigWs = null;
  }
  _sigReconnectDelay = 5000;
  _myPubKey = null;

  await initSignaling();
}

// Retry connection to a newly-added buddy.
// Awaits registration completion before sending relay messages, so the server's
// mutual buddy check doesn't silently drop them.
function _ensureBuddyConnection(pubKeyB64) {
  (async () => {
    // Wait for current registration to complete (server needs to know our updated buddy list)
    await _regReady;
    if (_relayConfirmed.has(pubKeyB64)) return;
    if (!getContacts().some(c => c.pubKeyB64 === pubKeyB64)) return;

    // Attempt 1: re-send hello on existing relay, or initiate new connection
    const entry = peerConns.get(pubKeyB64);
    if (entry?.relay && _wsRelayPeers.has(pubKeyB64)) {
      const id = await getIdentity();
      if (id && peerConns.get(pubKeyB64)?.relay) {
        console.log(`[p2p] ensure: re-sending hello to ${pubKeyB64.slice(0,8)}…`);
        _dcSend(peerConns.get(pubKeyB64).dc, { type: 'hello', pubKey: id.pubKeyB64, protocolVersion: 2 });
        await _sendMyJournal(peerConns.get(pubKeyB64).dc, pubKeyB64);
      }
    } else if (!peerConns.has(pubKeyB64)) {
      _autoConnQueue.push({ role: 'initiator', pubKey: pubKeyB64 });
      _runAutoConnQueue();
    }

    // Attempt 2: 3s later -- tear down dead relay and rebuild from scratch
    await new Promise(r => setTimeout(r, 3000));
    if (_relayConfirmed.has(pubKeyB64)) return;
    if (!getContacts().some(c => c.pubKeyB64 === pubKeyB64)) return;
    if (peerConns.has(pubKeyB64) && !_relayConfirmed.has(pubKeyB64)) {
      console.log(`[p2p] ensure: tearing down unconfirmed relay to ${pubKeyB64.slice(0,8)}… and retrying`);
      peerConns.delete(pubKeyB64);
      _wsRelayPeers.delete(pubKeyB64);
      _helloedPeers.delete(pubKeyB64);
      updateContactOnline(pubKeyB64, false);
    }
    _sigRegister();
    await _regReady;
    if (_relayConfirmed.has(pubKeyB64)) return;
    if (!getContacts().some(c => c.pubKeyB64 === pubKeyB64)) return;
    _autoConnQueue.push({ role: 'initiator', pubKey: pubKeyB64 });
    _runAutoConnQueue();
  })().catch(e => console.warn('[p2p] _ensureBuddyConnection error:', e));
}

async function _runAutoConnQueue() {
  if (_autoConnRunning) return;
  _autoConnRunning = true;
  while (_autoConnQueue.length) {
    const task = _autoConnQueue.shift();
    try { await _doAutoConn(task); } catch(e) { console.warn('[signal] auto-connect:', e); }
    if (_autoConnQueue.length) await new Promise(r => setTimeout(r, 1000));
  }
  _autoConnRunning = false;
}

async function _doAutoConn(task) {
  if (_relayConfirmed.has(task.pubKey)) return; // relay confirmed working
  if (!getContacts().some(c => c.pubKeyB64 === task.pubKey && !c.forgottenAt && !c.pendingRemoval && !c.removedAt)) return;

  if (!_sigWs || _sigWs.readyState !== WebSocket.OPEN) {
    console.log(`[p2p] skipping relay to ${task.pubKey.slice(0,8)}… -- signal WS not open`);
    return;
  }
  // Ensure our registration is complete before relaying -- server's mutual buddy check
  // will silently drop messages if our re-registration hasn't been processed yet
  await _regReady;
  if (!_sigWs || _sigWs.readyState !== WebSocket.OPEN) return;

  console.log(`[p2p] relay-connecting to ${task.pubKey.slice(0,8)}…`);
  _wsRelayPeers.add(task.pubKey);
  _relayConfirmed.delete(task.pubKey); // reset -- need fresh confirmation
  const relayDc = _makeRelayDc(task.pubKey);
  peerConns.set(task.pubKey, { pc: null, dc: relayDc, relay: true });
  updatePeerStatus();
  const identity = await getIdentity();
  if (identity && _wsRelayPeers.has(task.pubKey)) {
    _dcSend(relayDc, { type: 'hello', pubKey: identity.pubKeyB64, protocolVersion: 2 });
    _helloedPeers.add(task.pubKey);
    await _sendJournalManifest(relayDc, task.pubKey);
  }
  // Health check: if no hello received within 5s, re-register and retry
  const _healthPk = task.pubKey;
  const _prevHc = _healthCheckTimers.get(_healthPk);
  if (_prevHc) clearTimeout(_prevHc);
  const _hcTimer = setTimeout(async () => {
    _healthCheckTimers.delete(_healthPk);
    if (_relayConfirmed.has(_healthPk)) return;
    if (!_wsRelayPeers.has(_healthPk)) return;
    console.log(`[p2p] relay health check failed for ${_healthPk.slice(0,8)}… -- re-registering`);
    _sigRegister();
    await _regReady;
    const entry = peerConns.get(_healthPk);
    if (!entry?.relay || _relayConfirmed.has(_healthPk)) return;
    const id = await getIdentity();
    if (id) {
      _dcSend(entry.dc, { type: 'hello', pubKey: id.pubKeyB64, protocolVersion: 2 });
      await _sendMyJournal(entry.dc, _healthPk);
    }
    // Stall detection: if still no confirmation 30s after this retry (35s total),
    // mark the peer offline so the UI doesn't show "Connecting..." forever.
    // peer_online will re-trigger connection when they come back.
    const _stallTimer = setTimeout(() => {
      _healthCheckTimers.delete(_healthPk + '_stall');
      if (_relayConfirmed.has(_healthPk) || !_wsRelayPeers.has(_healthPk)) return;
      console.log(`[p2p] relay stall: ${_healthPk.slice(0,8)}… no response in 35s -- marking offline`);
      peerConns.delete(_healthPk);
      _wsRelayPeers.delete(_healthPk);
      _helloedPeers.delete(_healthPk);
      _relayConfirmed.delete(_healthPk);
      _stopRelayPing(_healthPk);
      updateContactOnline(_healthPk, false);
      updatePeerStatus();
    }, 30000);
    _healthCheckTimers.set(_healthPk + '_stall', _stallTimer);
  }, 5000);
  _healthCheckTimers.set(_healthPk, _hcTimer);
}

function _updateSigDot(connected) {
  const dot = document.getElementById('sigDot');
  if (!dot) return;
  const offlineMode = p2pIsOfflineMode();
  dot.title = offlineMode ? 'Buddy sync: offline (paused)' : connected ? 'Online - auto-connect active' : 'Buddy sync: offline';
  dot.classList.toggle('on', !!(connected && !offlineMode));
}

function p2pIsOfflineMode() {
  // Default is offline -- user must explicitly go online ('0' = online, anything else = offline)
  return localStorage.getItem('frj_p2p_offline') !== '0';
}

function p2pSetOffline(goOffline) {
  if (goOffline) {
    // Notify connected buddies we're going offline (curtain = idle)
    for (const [pk, entry] of peerConns) {
      if (entry?.dc?.readyState === 'open') {
        _dcSend(entry.dc, { type: 'curtain', idle: true });
      }
    }
    // Close all relay connections
    peerConns.clear();
    _wsRelayPeers.clear();
    _relayConfirmed.clear();
    _helloedPeers.clear();
    _autoConnQueue.length = 0;
    _autoConnRunning = false;
    // Close signal WS -- server will send peer_offline to all buddies
    if (_sigWs) {
      _sigWs.onclose = null;
      try { _sigWs.close(); } catch(e) {}
      _sigWs = null;
    }
    if (_reregInterval) { clearInterval(_reregInterval); _reregInterval = null; }
    if (_pingInterval)  { clearInterval(_pingInterval);  _pingInterval  = null; }
    _updateSigDot(false);
    updatePeerStatus();
    if (typeof renderContactsList === 'function') renderContactsList();
  } else {
    _sigReconnectDelay = 5000;
    initSignaling();
  }
}

// Evict RAM-only images for a peer from imgCache so non-cached photos go dark immediately.
// Also deletes from IDB as defense-in-depth (in case a photo was incorrectly persisted).
function _evictSessionOnlyImgsForPeer(pubKeyB64) {
  const prefix = `c_${pubKeyB64.slice(0, 16)}_`;
  for (const key of [..._sessionOnlyImgKeys]) {
    if (key.startsWith(prefix)) {
      if (imgCache[key]) { URL.revokeObjectURL(imgCache[key]); delete imgCache[key]; }
      // Belt-and-suspenders: also remove from IDB in case it was incorrectly written
      if (typeof idbDel === 'function') idbDel(key).catch(() => {});
      _sessionOnlyImgKeys.delete(key);
    }
  }
}

// Close buddy peer connections without touching the signal WS (device sync stays up).
// Call when the Buddies toggle is turned off.
function p2pDisconnectBuddies() {
  // Evict session-only images and ephemeral journals for all connected peers
  for (const pk of peerConns.keys()) {
    _evictSessionOnlyImgsForPeer(pk);
    _ephemeralJournals.delete(pk);
    _imgTransfer.delete(pk);
    _stopRelayPing(pk);
    updateContactOnline(pk, false);
  }
  peerConns.clear();
  _wsRelayPeers.clear();
  _relayConfirmed.clear();
  _helloedPeers.clear();
  _sessionSynced.clear();
  _autoConnQueue.length = 0;
  _autoConnRunning = false;
  updatePeerStatus();
  if (typeof renderContactsList === 'function') renderContactsList();
}

// Resume after mobile background -- called from visibilitychange in app.js.
// Mobile browsers suspend timers and may kill the WebSocket while backgrounded.
// This immediately reconnects if needed, or re-registers if the WS survived
// but the server-side presence likely expired (90s timeout, heartbeat was suspended).
function p2pResumeAfterBackground() {
  if (p2pIsOfflineMode() || !_myPubKey) return;
  if (!_sigWs || _sigWs.readyState !== WebSocket.OPEN) {
    // WS died during background -- reconnect immediately (fast backoff for instant resume)
    _sigReconnectDelay = 1000;
    _reconnectAttempts.clear();
    _sigConnect();
  } else {
    // WS survived but presence heartbeat was likely suspended -- re-register
    // so the server refreshes our presence and buddy/device routing
    _reconnectAttempts.clear();
    _sigRegister();
    // Also re-send presence heartbeat immediately
    if (_presenceId) {
      _sigWs.send(JSON.stringify({ type: 'presence_heartbeat', presenceId: _presenceId }));
    }
  }
}

// Deregister presence + stop heartbeats WITHOUT killing the WS connection.
// Use when buddies are turned off but network (device sync) should stay up.
function p2pDeregisterPresenceOnly() {
  if (_presenceState === 'public') p2pSendDirectoryRemove();
  if (_presenceId) _sigSend({ type: 'presence_deregister', presenceId: _presenceId });
  _presenceId = null; _presenceEmoji = null; _presenceState = 'offline';
  if (_presenceHeartbeatInterval) { clearInterval(_presenceHeartbeatInterval); _presenceHeartbeatInterval = null; }
  if (_handshakeCheckInterval)    { clearInterval(_handshakeCheckInterval);    _handshakeCheckInterval    = null; }
  if (_presenceListInterval)      { clearInterval(_presenceListInterval);      _presenceListInterval      = null; }
  if (_directoryListInterval)     { clearInterval(_directoryListInterval);     _directoryListInterval     = null; }
  _restorersOnline = [];
  _directoryEntries = [];
  _incomingRequests = [];
  _persistIncomingRequests();
  if (typeof updatePresenceToggleUI  === 'function') updatePresenceToggleUI('offline');
  if (typeof updateRestorersOnlineUI === 'function') updateRestorersOnlineUI();
}

// ═══════════════════════════════════════════════
// Buddy Network -- Presence & Handshake client
// ═══════════════════════════════════════════════

let _presenceState = 'offline'; // 'offline' | 'private' | 'public'
let _presenceId = null;
let _presenceEmoji = null;
let _ownPubKeyHash = null; // cached -- same across all our devices, used to self-filter restorer lists
let _presenceHeartbeatInterval = null;
let _handshakeCheckInterval = null;
let _presenceListInterval = null;
let _pendingHandshake = null; // { handshakeId, role, stage, theirPartial }
let _restorersOnline = []; // [{ presenceId, emoji }]
let _directoryEntries = []; // [{ hash, emoji, displayName, lastSeen, online }]
let _incomingRequests = (() => {
  try { return JSON.parse(localStorage.getItem('frj_incoming_buddy_requests') || '[]'); } catch(e) { return []; }
})(); // [{ fromHash, fromEmoji, fromName, ts }]

function _persistIncomingRequests() {
  try { localStorage.setItem('frj_incoming_buddy_requests', JSON.stringify(_incomingRequests)); } catch(e) {}
}
let _directoryListInterval = null;

// Compute first 16 chars of base64url SHA-256 of the raw public key bytes.
// Used as a stable, pseudonymous identifier for offer matching.
async function _computePubKeyHash() {
  const identity = await getIdentity();
  if (!identity) return null;
  // The raw public key is stored in IDB; retrieve it directly for hashing
  const pubRaw = await idbGet('frj_keypair_pub');
  if (!pubRaw) return null;
  return _hashRawPubKey(pubRaw);
}

// Hash any raw public key bytes (or base64url pubKey string) → 16-char base64url
async function _hashRawPubKey(rawOrB64) {
  let buf;
  if (typeof rawOrB64 === 'string') {
    // base64url pubKey string → ArrayBuffer
    const b64std = rawOrB64.replace(/-/g, '+').replace(/_/g, '/');
    const binary = atob(b64std);
    buf = new Uint8Array(binary.length);
    for (let i = 0; i < binary.length; i++) buf[i] = binary.charCodeAt(i);
  } else {
    buf = rawOrB64;
  }
  const hashBuf = await crypto.subtle.digest('SHA-256', buf);
  const bytes = new Uint8Array(hashBuf);
  let b64 = btoa(String.fromCharCode(...bytes));
  b64 = b64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
  return b64.slice(0, 16);
}

function _sigSend(obj) {
  if (!_sigWs || _sigWs.readyState !== WebSocket.OPEN) return false;
  _sigWs.send(JSON.stringify(obj));
  return true;
}

async function setPresenceState(state) {
  if (state === _presenceState) return;

  // Going offline -- remove from directory, deregister presence, and fully disconnect
  if (state === 'offline') {
    if (_presenceState === 'public') p2pSendDirectoryRemove();
    if (_presenceId) _sigSend({ type: 'presence_deregister', presenceId: _presenceId });
    _presenceId = null;
    _presenceEmoji = null;
    _presenceState = 'offline';
    if (_presenceHeartbeatInterval) { clearInterval(_presenceHeartbeatInterval); _presenceHeartbeatInterval = null; }
    if (_handshakeCheckInterval) { clearInterval(_handshakeCheckInterval); _handshakeCheckInterval = null; }
    if (_presenceListInterval) { clearInterval(_presenceListInterval); _presenceListInterval = null; }
    if (_directoryListInterval) { clearInterval(_directoryListInterval); _directoryListInterval = null; }
    _restorersOnline = [];
    _directoryEntries = [];
    _incomingRequests = [];
    _persistIncomingRequests();
    // Persist offline flag so the WS stays closed on reload
    localStorage.setItem('frj_p2p_offline', '1');
    p2pSetOffline(true);
    if (typeof _applyP2POffline === 'function') _applyP2POffline();
    if (typeof updatePresenceToggleUI === 'function') updatePresenceToggleUI('offline');
    if (typeof updateRestorersOnlineUI === 'function') updateRestorersOnlineUI();
    return;
  }

  // Going private or public -- ensure p2p is online, then register presence
  if (p2pIsOfflineMode()) {
    localStorage.setItem('frj_p2p_offline', '0');
    p2pSetOffline(false);
    if (typeof _applyP2POffline === 'function') _applyP2POffline();
  }

  if (!_sigWs || _sigWs.readyState !== WebSocket.OPEN) {
    // WS not ready yet -- store desired state and apply once connected
    _presenceState = state;
    _updatePresenceDots();
    if (typeof updatePresenceToggleUI === 'function') updatePresenceToggleUI(state);
    return;
  }

  const profile = (typeof getProfile === 'function') ? getProfile() : {};
  _presenceEmoji = profile.avatar || '🌿';
  const pubKeyHash = await _computePubKeyHash();
  if (!pubKeyHash) return;
  _ownPubKeyHash = pubKeyHash;

  _presenceState = state;
  _updatePresenceDots();
  if (typeof updatePresenceToggleUI === 'function') updatePresenceToggleUI(state);
  _updateRestorersOnlineUI(); // immediately update contacts page nudge/sections

  // Block list is the only consent gate -- acceptRequests removed (Council Phase L+1).
  _sigSend({ type: 'presence_register', emoji: _presenceEmoji, pubKeyHash, mode: state });
  // presence_registered response handled in onmessage → starts heartbeat

  // Start handshake check poll
  if (!_handshakeCheckInterval) {
    _handshakeCheckInterval = setInterval(async () => {
      if (_presenceState === 'offline') return;
      const hash = await _computePubKeyHash();
      if (hash) _sigSend({ type: 'handshake_check', myKeyHash: hash });
    }, 30000);
    // Run immediately
    (async () => {
      const hash = await _computePubKeyHash();
      if (hash) _sigSend({ type: 'handshake_check', myKeyHash: hash });
    })();
  }

  // If public, fetch/poll presence list + directory
  if (state === 'public') {
    _sigSend({ type: 'presence_list' });
    if (!_presenceListInterval) {
      _presenceListInterval = setInterval(() => {
        if (_presenceState === 'public') _sigSend({ type: 'presence_list' });
      }, 30000);
    }
    // Directory: send update + fetch + poll every 60s
    p2pSendDirectoryUpdate();
    p2pRequestDirectory();
    if (!_directoryListInterval) {
      _directoryListInterval = setInterval(() => {
        if (_presenceState === 'public') p2pRequestDirectory();
      }, 60000);
    }
  } else {
    // Going private -- remove from directory, stop list polling, clear public list
    p2pSendDirectoryRemove();
    if (_presenceListInterval) { clearInterval(_presenceListInterval); _presenceListInterval = null; }
    if (_directoryListInterval) { clearInterval(_directoryListInterval); _directoryListInterval = null; }
    _restorersOnline = [];
    _directoryEntries = [];
    if (typeof updateRestorersOnlineUI === 'function') updateRestorersOnlineUI();
  }
}

function p2pGetPresenceState() { return _presenceState; }
function p2pGetRestorersOnline() { return _restorersOnline; }
function p2pGetDirectory() { return _directoryEntries; }
function p2pGetIncomingRequests() { return _incomingRequests; }

// Send emoji + displayName to server after going public
async function p2pSendDirectoryUpdate() {
  const hash = await _computePubKeyHash();
  if (!hash) return;
  const profile = (typeof getProfile === 'function') ? getProfile() : {};
  const pubSettings = (typeof getPublicProfileSettings === 'function') ? getPublicProfileSettings() : {};
  const displayName = pubSettings.showName ? (lsGet('frj_displayname', '') || null) : null;
  _sigSend({ type: 'directory_update', pubKeyHash: hash, emoji: profile.avatar || '🌿', displayName });
}

// Remove ourselves from the server directory (right-to-delete)
async function p2pSendDirectoryRemove() {
  const hash = await _computePubKeyHash();
  if (hash) _sigSend({ type: 'directory_remove', pubKeyHash: hash });
}

// Request the full 30-day directory from server
function p2pRequestDirectory() {
  _sigSend({ type: 'directory_list' });
}

// Send an async buddy request (works even if target is offline)
async function p2pSendBuddyRequest(targetHash, targetEmoji, targetName) {
  if (!targetHash) return;
  // Reaching out clears any prior decline of this person, so if they respond
  // (or re-request), it surfaces again instead of being silently suppressed.
  if (typeof clearDeclinedRequest === 'function') clearDeclinedRequest(targetHash);
  _sigSend({ type: 'buddy_request', targetHash });
  // Track as pending exchange
  if (!_pendingExchanges.some(e => e.targetHash === targetHash && e.status !== 'completed')) {
    _pendingExchanges.push({ targetHash, targetEmoji: targetEmoji || '🌿', targetName: targetName || null, sentAt: Date.now(), status: 'pending' });
    _persistPendingExchanges();
  }
}

// Accept an incoming buddy request + deposit own buddy code
async function p2pAcceptBuddyRequest(fromHash) {
  if (!fromHash) return;
  const identity = await getIdentity();
  if (!identity) return;
  const buddyCode = `FR1:${identity.pubKeyB64}`;
  _sigSend({ type: 'buddy_request_accept', fromHash, buddyCode });
  // Remove from local incoming list
  _incomingRequests = _incomingRequests.filter(r => r.fromHash !== fromHash);
  _persistIncomingRequests();
  _updateRestorersOnlineUI();
}

let _pendingOutgoingOffers = []; // [{ presenceId, emoji, sentAt }]
function p2pGetPendingOffers() { return _pendingOutgoingOffers; }

// ── Pending buddy exchanges (persisted in localStorage) ────────────────────────
// [{ targetHash, targetEmoji, targetName, sentAt, status }]
let _pendingExchanges = (() => {
  try {
    const raw = JSON.parse(localStorage.getItem('frj_pending_exchanges') || '[]');
    const now = Date.now();
    // Expire entries older than 7 days, prune completed entries older than 1 day
    return raw.filter(e => {
      if (now - e.sentAt > 7 * 86400000) return false;
      if (e.status === 'completed' && now - e.sentAt > 86400000) return false;
      return true;
    });
  } catch(e) { return []; }
})();

function _persistPendingExchanges() {
  try { localStorage.setItem('frj_pending_exchanges', JSON.stringify(_pendingExchanges)); } catch(e) {}
}

function p2pGetPendingExchanges() {
  return _pendingExchanges.filter(e => e.status !== 'completed');
}

function p2pCancelPendingExchange(targetHash) {
  _pendingExchanges = _pendingExchanges.filter(e => e.targetHash !== targetHash);
  _persistPendingExchanges();
  if (typeof updateRestorersOnlineUI === 'function') updateRestorersOnlineUI();
}

// Block a restorer by their stable pubKeyHash -- persists across sessions
function p2pBlockPresence(hash) {
  if (!hash) return;
  if (typeof addBlockedHash === 'function') addBlockedHash(hash);
  _sigSend({ type: 'presence_block', hash }); // tell server to filter them for this session
  _restorersOnline = _restorersOnline.filter(r => r.hash !== hash);
  _directoryEntries = _directoryEntries.filter(r => r.hash !== hash);
  _incomingRequests = _incomingRequests.filter(r => r.fromHash !== hash);
  _persistIncomingRequests();
  _updateRestorersOnlineUI();
}

function _updatePresenceDots() {
  if (typeof updatePresenceToggleUI === 'function') updatePresenceToggleUI(_presenceState);
}

function _updateRestorersOnlineUI() {
  if (typeof updateRestorersOnlineUI === 'function') updateRestorersOnlineUI();
}

// ── Handshake send helpers ────────────────────────────────────────────────────

async function p2pSendHandshakeOffer(toPresenceId, emoji) {
  const hash = await _computePubKeyHash();
  if (!hash) return;
  _sigSend({ type: 'handshake_offer', myKeyHash: hash, toPresenceId });
  _pendingOutgoingOffers = _pendingOutgoingOffers.filter(o => o.presenceId !== toPresenceId);
  _pendingOutgoingOffers.push({ presenceId: toPresenceId, emoji: emoji || '?', sentAt: Date.now() });
  if (typeof updateRestorersOnlineUI === 'function') updateRestorersOnlineUI();
}

async function p2pCancelOffer(presenceId) {
  _pendingOutgoingOffers = _pendingOutgoingOffers.filter(o => o.presenceId !== presenceId);
  const hash = await _computePubKeyHash();
  if (hash) _sigSend({ type: 'handshake_cancel', myKeyHash: hash }); // server will clean up matching offers
  if (typeof updateRestorersOnlineUI === 'function') updateRestorersOnlineUI();
}

async function p2pSendHandshakePartial(handshakeId, partialPayload) {
  const hash = await _computePubKeyHash();
  if (!hash) return;
  _sigSend({ type: 'handshake_partial', handshakeId, myKeyHash: hash, payload: JSON.stringify(partialPayload) });
}

async function p2pConfirmHandshake(handshakeId) {
  const hash = await _computePubKeyHash();
  if (!hash) return;
  _sigSend({ type: 'handshake_confirm', handshakeId, myKeyHash: hash });
}

async function p2pSendHandshakeExchange(handshakeId, fullBuddyCode) {
  const hash = await _computePubKeyHash();
  if (!hash) return;
  _sigSend({ type: 'handshake_exchange', handshakeId, myKeyHash: hash, payload: fullBuddyCode });
}

async function p2pCancelHandshake(handshakeId) {
  const hash = await _computePubKeyHash();
  if (!hash) return;
  _sigSend({ type: 'handshake_cancel', handshakeId, myKeyHash: hash });
  _pendingHandshake = null;
}

// ── Incoming buddy code from exchange ─────────────────────────────────────────

// The relay re-sends buddy_exchange_ready on every reconnect until our ack lands.
// Reconnects are frequent (the relay host spins down when idle), so without this
// dedup every re-delivery fired another "adding contact" + "now friends" toast.
const _SEEN_EXCHANGES_KEY = 'frj_seen_exchanges';
function _exchangeSeen(exchangeId) {
  if (!exchangeId) return false;
  try { return (JSON.parse(localStorage.getItem(_SEEN_EXCHANGES_KEY)) || []).includes(exchangeId); }
  catch (e) { return false; }
}
function _markExchangeSeen(exchangeId) {
  if (!exchangeId) return;
  try {
    const seen = JSON.parse(localStorage.getItem(_SEEN_EXCHANGES_KEY)) || [];
    if (!seen.includes(exchangeId)) {
      seen.push(exchangeId);
      localStorage.setItem(_SEEN_EXCHANGES_KEY, JSON.stringify(seen.slice(-100)));
    }
  } catch (e) { /* non-fatal */ }
}

function _handleBuddyCodeReceived(buddyCode) {
  if (typeof buddyCode !== 'string' || !buddyCode.startsWith('FR1:')) {
    console.warn('[p2p] handshake_exchange: invalid buddy code received');
    return;
  }
  let newPubKeyB64 = null;
  let isNewBuddy = false;
  // Re-use the existing addContact flow from contacts.js
  if (typeof parseIdentityCode === 'function' && typeof getContacts === 'function' && typeof saveContacts === 'function') {
    const parsed = parseIdentityCode(buddyCode);
    if (!parsed) { console.warn('[p2p] handshake_exchange: buddy code parse failed'); return; }
    newPubKeyB64 = parsed.pubKeyB64;
    const contacts = getContacts();
    const existing = contacts.find(c => c.pubKeyB64 === parsed.pubKeyB64);
    if (!existing) {
      isNewBuddy = true;
      contacts.push({
        pubKeyB64: parsed.pubKeyB64,
        nickname: 'Buddy-' + parsed.pubKeyB64.slice(0, 6),
        lastSeen: null,
        journal: null,
        updatedAt: Date.now(),
        senderViewScope: 'none',
        senderCacheScope: 'none',
        senderAllowsCache: false
      });
      saveContacts(contacts);
    } else if (existing.removedAt || existing.pendingRemoval || existing.forgottenAt || existing.notBuddiesYet) {
      // Re-activate a previously removed/blocked/pending contact
      isNewBuddy = true;
      delete existing.removedAt;
      delete existing.pendingRemoval;
      delete existing.pendingRemovalAt;
      delete existing.forgottenAt;
      delete existing.vanished;
      delete existing.notBuddiesYet;
      existing.updatedAt = Date.now();
      saveContacts(contacts);
    }
    // Always re-register + reconnect -- even if contact existed, the WS buddy list may be stale
    if (typeof p2pSigRegister === 'function') p2pSigRegister();
    if (typeof syncAfterSave === 'function') syncAfterSave();
    if (typeof renderContactsList === 'function') renderContactsList();
    _ensureBuddyConnection(parsed.pubKeyB64);
    // Push our latest snapshot + photo to the relay queue so the new buddy sees
    // something next time they come online. Without this, the on-connect snapshot
    // push (fires before this contact existed) silently skipped them, and they'd
    // only get our photo once we save a new journal entry.
    if (typeof p2pPushBuddySnapshot === 'function') p2pPushBuddySnapshot();
  }
  const liveEmoji = _pendingHandshake?.theirPartial?.emoji || null;
  _pendingHandshake = null;
  // Only announce genuinely new (or re-activated) buddies -- an exchange for a
  // contact that already exists and is active is a repeat, not a new friendship.
  if (!isNewBuddy) return;
  // Build "You and 🌿 Restorer Name are now friends!" -- look up the emoji + name
  // from whichever side of the exchange surfaced them first: the live handshake
  // partial, the pending exchange we sent, the incoming request we accepted, or
  // the public directory entry. The hash lookup is async so we fire-and-forget.
  (async () => {
    let emoji = liveEmoji || '🌿';
    let name = '';
    try {
      const hash = (newPubKeyB64 && typeof _hashRawPubKey === 'function')
        ? await _hashRawPubKey(newPubKeyB64) : null;
      if (hash) {
        const pe = Array.isArray(_pendingExchanges) ? _pendingExchanges.find(e => e.targetHash === hash) : null;
        if (pe) { emoji = pe.targetEmoji || emoji; name = pe.targetName || name; }
        if (!name) {
          const ir = Array.isArray(_incomingRequests) ? _incomingRequests.find(r => r.fromHash === hash) : null;
          if (ir) { emoji = ir.fromEmoji || emoji; name = ir.fromName || name; }
        }
        if (!name) {
          const dir = Array.isArray(_directoryEntries) ? _directoryEntries.find(d => d.hash === hash) : null;
          if (dir) { emoji = dir.emoji || emoji; name = dir.displayName || name; }
        }
      }
      if (!name && newPubKeyB64) {
        const c = getContacts().find(c => c.pubKeyB64 === newPubKeyB64);
        if (c?.journal?.displayName) name = c.journal.displayName;
      }
    } catch (e) { /* fall through to emoji-only label */ }
    if (typeof toast === 'function') {
      const ident = name ? `${emoji} ${name}` : emoji;
      toast(`You and ${ident} are now friends!`);
    }
  })();
}

// Single-slot async profile fetcher -- resolves when profile_data arrives (or 4s timeout)
let _profileRequestResolve = null;

function _handleProfileData(payload) {
  if (_profileRequestResolve) {
    _profileRequestResolve(payload);
  } else if (typeof showProfileDataModal === 'function') {
    showProfileDataModal(payload);
  }
}

async function p2pRequestProfile(targetPresenceId) {
  const hash = await _computePubKeyHash();
  if (!hash) return null;
  _sigSend({ type: 'profile_request', myKeyHash: hash, targetPresenceId });
  return new Promise(resolve => {
    const timer = setTimeout(() => { _profileRequestResolve = null; resolve(null); }, 4000);
    _profileRequestResolve = payload => { clearTimeout(timer); _profileRequestResolve = null; resolve(payload); };
  });
}

// ── Buddy Network incoming message handlers (added to onmessage in _sigConnect) ──
// These are registered after the existing handlers via the _buddyNetHandlers dispatcher.

async function _handleBuddyNetMessage(msg) {
  if (msg.type === 'presence_registered') {
    _presenceId = msg.presenceId;
    // Re-send blocklist so server filters presence_list for this session
    if (typeof getBlockedHashes === 'function') {
      (getBlockedHashes() || []).forEach(hash => _sigSend({ type: 'presence_block', hash }));
    }
    // Start heartbeat
    if (_presenceHeartbeatInterval) clearInterval(_presenceHeartbeatInterval);
    _presenceHeartbeatInterval = setInterval(() => {
      if (_presenceId && _sigWs && _sigWs.readyState === WebSocket.OPEN) {
        _sigWs.send(JSON.stringify({ type: 'presence_heartbeat', presenceId: _presenceId }));
      }
    }, 60000);
    // If we already think we're idle (e.g. registered while tab was hidden),
    // tell the server now so the orange orb propagates immediately.
    if (_localIdle && _sigWs && _sigWs.readyState === WebSocket.OPEN) {
      _sigSend({ type: 'presence_idle', idle: true });
    }
    // If public, send directory update + request directory
    if (_presenceState === 'public') {
      p2pSendDirectoryUpdate();
      p2pRequestDirectory();
    }
    // Reconcile pending exchanges with server -- fire immediately on register.
    // The previous 3s delay meant a fresh-page user briefly saw "pending" rows
    // that were actually stale or already accepted on the server side.
    {
      const pending = p2pGetPendingExchanges();
      if (pending.length) {
        _sigSend({ type: 'check_pending_exchanges', targetHashes: pending.map(e => e.targetHash) });
      }
    }

  } else if (msg.type === 'pending_exchange_status') {
    // Server response with status of our pending exchanges
    const results = msg.results;
    if (results && typeof results === 'object') {
      let changed = false;
      for (const e of _pendingExchanges) {
        if (e.status === 'completed') continue;
        const serverStatus = results[e.targetHash];
        if (serverStatus === 'unknown') {
          // Server doesn't know about this request -- it expired or was never received
          // Mark as expired if older than 24h, otherwise leave as pending (server may have restarted)
          if (Date.now() - e.sentAt > 86400000) {
            e.status = 'completed'; // will be pruned on next load
            changed = true;
          }
        }
        // 'queued' and 'accepted' mean it's still in progress -- no change needed
      }
      if (changed) {
        _persistPendingExchanges();
        if (typeof updateRestorersOnlineUI === 'function') updateRestorersOnlineUI();
      }
    }

  } else if (msg.type === 'presence_list') {
    const _blocked = typeof getBlockedHashes === 'function' ? new Set(getBlockedHashes()) : new Set();
    _restorersOnline = Array.isArray(msg.list)
      ? msg.list.filter(x => x && x.presenceId && x.emoji && !_blocked.has(x.hash) && x.presenceId !== _presenceId && x.hash !== _ownPubKeyHash)
      : [];
    _updateRestorersOnlineUI();

  } else if (msg.type === 'handshake_match') {
    // Ignore duplicate match notifications for an already-known handshake
    if (_pendingHandshake && _pendingHandshake.handshakeId === msg.handshakeId) return;
    _pendingHandshake = { handshakeId: msg.handshakeId, role: msg.role, stage: 2, theirPartial: null };
    // Clear our pending offer for the other party (match found -- no longer pending)
    _pendingOutgoingOffers = [];
    // Send our partial identity immediately
    (async () => {
      const identity = await getIdentity();
      const profile = (typeof getProfile === 'function') ? getProfile() : {};
      const hash = await _computePubKeyHash();
      if (!identity || !hash) return;
      const partial = {
        emoji: profile.avatar || '🌿',
        displayName: identity.displayName || 'Restorer',
        buddyCodePrefix: identity.pubKeyB64.slice(0, 4)
      };
      p2pSendHandshakePartial(msg.handshakeId, partial);
    })();
    // Show match notification in UI
    if (typeof showHandshakeMatchModal === 'function') {
      // Wait briefly to receive their partial before showing modal
      setTimeout(() => {
        if (_pendingHandshake && _pendingHandshake.handshakeId === msg.handshakeId) {
          showHandshakeMatchModal(msg.handshakeId, msg.role, _pendingHandshake.theirPartial);
        }
      }, 2500);
    }

  } else if (msg.type === 'handshake_partial') {
    if (_pendingHandshake && _pendingHandshake.handshakeId === msg.handshakeId) {
      try { _pendingHandshake.theirPartial = JSON.parse(msg.payload); } catch(e) { _pendingHandshake.theirPartial = msg.payload; }
    }

  } else if (msg.type === 'handshake_stage3') {
    if (_pendingHandshake && _pendingHandshake.handshakeId === msg.handshakeId) {
      _pendingHandshake.stage = 3;
      // Send our full buddy code as the exchange payload
      (async () => {
        const identity = await getIdentity();
        if (!identity) return;
        const fullCode = `FR1:${identity.pubKeyB64}`;
        await p2pSendHandshakeExchange(msg.handshakeId, fullCode);
      })();
    }

  } else if (msg.type === 'handshake_exchange') {
    _handleBuddyCodeReceived(msg.payload);

  } else if (msg.type === 'handshake_cancelled') {
    if (_pendingHandshake && _pendingHandshake.handshakeId === msg.handshakeId) {
      _pendingHandshake = null;
      if (typeof closeModal === 'function') closeModal('handshakeModal');
    }

  } else if (msg.type === 'directory_list') {
    const _blocked = typeof getBlockedHashes === 'function' ? new Set(getBlockedHashes()) : new Set();
    _directoryEntries = Array.isArray(msg.list)
      ? msg.list.filter(x => x && x.hash && !_blocked.has(x.hash) && x.hash !== _ownPubKeyHash)
      : [];
    _updateRestorersOnlineUI();

  } else if (msg.type === 'buddy_request_received') {
    // Incoming buddy requests (delivered on connect or real-time). Block list is
    // the only consent gate -- the server already filtered blocked senders, so
    // anything that arrives here is meant to be surfaced. Council Phase L+1
    // removed the client-side acceptRequests filter that silently discarded
    // an already-delivered message.
    const reqs = Array.isArray(msg.requests) ? msg.requests : [];
    let added = 0;
    let firstNew = null;
    for (const r of reqs) {
      if (!r.fromHash) continue;
      // Suppress requests the user already declined -- the relay may still be
      // redelivering them (old relay, or a lost decline message).
      if (typeof isRequestDeclined === 'function' && isRequestDeclined(r.fromHash)) continue;
      // Don't duplicate (idempotent against rebroadcast on reconnect)
      if (_incomingRequests.some(x => x.fromHash === r.fromHash)) continue;
      _incomingRequests.push(r);
      if (!firstNew) firstNew = r;
      added++;
    }
    if (added > 0) {
      _persistIncomingRequests();
      _updateRestorersOnlineUI();
      // Toast for the first new request -- ensures the recipient sees a moment
      // of arrival rather than discovering it in a dropdown later. Multiple
      // simultaneous requests (e.g. on connect after offline) batch into one
      // toast so we don't carpet-bomb the user.
      if (typeof toast === 'function') {
        const emoji = firstNew?.fromEmoji || '🌿';
        const name = firstNew?.fromName || 'A restorer';
        const msgText = added === 1
          ? `${emoji} ${name} sent you a buddy request`
          : `${emoji} ${added} new buddy requests`;
        toast(msgText);
      }
    }

  } else if (msg.type === 'buddy_request_sent') {
    if (typeof toast === 'function') toast('Buddy request sent!');

  } else if (msg.type === 'buddy_request_failed') {
    // Server told us this request will NOT be delivered. Mark any matching
    // pending exchange as failed so the UI stops claiming it's in flight.
    const reason = typeof msg.reason === 'string' ? msg.reason : 'unknown';
    const reasonText = {
      rate_limit: 'You\'ve sent too many buddy requests recently. Try again in an hour.',
      capacity:   'The server is at capacity. Try again later.',
      target_cap: 'That restorer has too many pending requests right now.',
      invalid:    'Couldn\'t send buddy request (invalid).'
    }[reason] || 'Buddy request could not be sent.';
    if (typeof toast === 'function') toast(reasonText, true);
    // If we know which target this was for, surface it on the pending entry.
    if (msg.targetHash) {
      const pe = _pendingExchanges.find(e => e.targetHash === msg.targetHash);
      if (pe) { pe.status = 'failed'; pe.failReason = reason; _persistPendingExchanges(); }
    }

  } else if (msg.type === 'buddy_request_accepted') {
    // We accepted a request -- exchange created
    if (typeof toast === 'function') toast('Request accepted -- waiting for buddy code exchange…');

  } else if (msg.type === 'buddy_exchange_ready') {
    // We received someone's buddy code from an async exchange.
    // The server re-sends this until our ack lands, so dedup by exchangeId:
    // on a re-delivery, skip processing but still re-deposit + re-ack below.
    const buddyCode = msg.buddyCode;
    if (buddyCode) {
      if (!_exchangeSeen(msg.exchangeId)) {
        _markExchangeSeen(msg.exchangeId);
        if (typeof toast === 'function') toast('Buddy code received -- adding contact…');
        _handleBuddyCodeReceived(buddyCode);
        // Mark pending exchange as completed
        if (msg.fromHash) {
          const pe = _pendingExchanges.find(e => e.targetHash === msg.fromHash);
          if (pe) { pe.status = 'completed'; _persistPendingExchanges(); }
        }
      }
      // If we need to deposit our own code (we're the requester side)
      if (msg.exchangeId) {
        (async () => {
          const identity = await getIdentity();
          if (!identity) return;
          const myCode = `FR1:${identity.pubKeyB64}`;
          _sigSend({ type: 'buddy_exchange_deposit', exchangeId: msg.exchangeId, buddyCode: myCode });
        })();
        // Ack receipt so server can clean up the exchange once both sides have acked
        _sigSend({ type: 'buddy_exchange_ack', exchangeId: msg.exchangeId });
      }
    }

  } else if (msg.type === 'buddy_mutual_match') {
    // Both sides sent requests -- deposit our code
    if (typeof toast === 'function') toast('Mutual buddy request matched! Exchanging codes…');
    // Mark pending exchange as completed
    if (msg.withHash) {
      const pe = _pendingExchanges.find(e => e.targetHash === msg.withHash);
      if (pe) { pe.status = 'completed'; _persistPendingExchanges(); }
    }
    (async () => {
      const identity = await getIdentity();
      if (!identity || !msg.exchangeId) return;
      const myCode = `FR1:${identity.pubKeyB64}`;
      _sigSend({ type: 'buddy_exchange_deposit', exchangeId: msg.exchangeId, buddyCode: myCode });
    })();

  } else if (msg.type === 'profile_request') {
    // Only respond if we are public
    if (_presenceState !== 'public' || !_presenceId) return;
    const profile = (typeof getProfile === 'function') ? getProfile() : {};
    const pubSettings = ((typeof getPublicProfileSettings === 'function') ? getPublicProfileSettings() : null) || {};
    const displayName = lsGet('frj_displayname', '') || 'Restorer';
    const publicProfile = {
      emoji:       profile.avatar || '🌿',
      displayName: pubSettings.showName ? displayName : null,
      ci:          pubSettings.showCurrentCI   ? profile.currentCI   : null,
      goalCI:      pubSettings.showTargetCI    ? profile.goalCI      : null,
      startingCI:  pubSettings.showStartingCI  ? profile.startingCI  : null,
      startDate:   pubSettings.showTimeRestoring ? profile.startDate : null,
    };
    _sigSend({ type: 'profile_response', toPresenceId: msg.fromPresenceId, payload: publicProfile });

  } else if (msg.type === 'profile_data') {
    _handleProfileData(msg.payload);

  // ── Chat relay messages (server → client) ──────────────────────────────────
  } else if (msg.type === 'chat_message') {
    // Encrypted message delivered by server relay (offline-queued or real-time via server)
    await _handleIncomingChatRelay(msg);

  } else if (msg.type === 'chat_ack') {
    // Server confirmed our queued message was delivered to the recipient
    if (typeof markChatDelivered === 'function' && msg.id) {
      await markChatDelivered(msg.id, msg.deliveredAt || Date.now());
      if (typeof _chatNotify === 'function') _chatNotify(msg.id, 'delivered');
    }

  } else if (msg.type === 'chat_expired') {
    // Server dropped our queued message after 24h -- recipient never came online
    if (typeof markChatFailed === 'function' && msg.id) {
      await markChatFailed(msg.id);
      if (typeof _chatNotify === 'function') _chatNotify(msg.id, 'expired');
    }

  // ── Timer status via signal server ─────────────────────────────────────────
  } else if (msg.type === 'buddy_idle_status') {
    // Buddy went idle / came back. Map hash → contact and toggle the local set.
    const _biFromHash = msg.fromHash;
    if (!_biFromHash) return;
    const _biContacts = (typeof getContacts === 'function') ? getContacts() : [];
    for (const _bic of _biContacts) {
      if (_bic.removedAt || _bic.forgottenAt) continue;
      const _bih = await _hashRawPubKey(_bic.pubKeyB64);
      if (_bih !== _biFromHash) continue;
      if (msg.idle) _buddyIdleStates.add(_bic.pubKeyB64);
      else _buddyIdleStates.delete(_bic.pubKeyB64);
      // Re-render dashboards / contact rows so the orb color updates.
      if (typeof renderDash === 'function') renderDash();
      if (typeof renderContactsList === 'function') renderContactsList();
      break;
    }

  } else if (msg.type === 'buddy_timer_status') {
    // Timer status from signal server -- delivered on connect or when buddy starts/stops
    const _btsPerms = (typeof getBuddyPermissions === 'function') ? getBuddyPermissions() : {};
    if (!_btsPerms.showBuddyTimerStatus) return;
    const _btsFromHash = msg.fromHash;
    if (!_btsFromHash) return;
    // Resolve hash → pubKeyB64 by scanning contacts
    const _btsContacts = (typeof getContacts === 'function') ? getContacts() : [];
    for (const _btsc of _btsContacts) {
      const _btsh = await _hashRawPubKey(_btsc.pubKeyB64);
      if (_btsh !== _btsFromHash) continue;
      // Found the contact -- apply timer state (same logic as buddy_timer P2P handler)
      const _btsStatus = msg.status;
      const _btsSa = Number(msg.startedAt);
      const _btsSid = (typeof msg.sessionId === 'string' && msg.sessionId.length <= 64) ? msg.sessionId : null;
      const _btsDev = (typeof msg.device === 'string' && msg.device.length <= 40) ? msg.device : null;
      const _btsTodayTugMs = Number(msg.todayTugMs) || 0;
      const _btsTodayRetMs = Number(msg.todayRetMs) || 0;
      if (_btsStatus === 'tugging' || _btsStatus === 'retaining') {
        if (!_btsSa || !isFinite(_btsSa)) break;
        _buddyTimerStates.set(_btsc.pubKeyB64, { type: _btsStatus, startedAt: _btsSa, id: _btsSid, device: _btsDev, todayTugMs: _btsTodayTugMs, todayRetMs: _btsTodayRetMs });
      } else {
        // Stopped -- preserve today totals if relay sent them, otherwise keep existing P2P values
        if (_btsTodayTugMs || _btsTodayRetMs) {
          _buddyTimerStates.set(_btsc.pubKeyB64, { type: null, startedAt: null, id: null, device: null, todayTugMs: _btsTodayTugMs, todayRetMs: _btsTodayRetMs });
        } else {
          const _btsExisting = _buddyTimerStates.get(_btsc.pubKeyB64);
          if (!_btsExisting || (!_btsExisting.todayTugMs && !_btsExisting.todayRetMs)) {
            _buddyTimerStates.delete(_btsc.pubKeyB64);
          }
        }
      }
      if (typeof _updateBuddyTimerBadge === 'function') _updateBuddyTimerBadge(_btsc.pubKeyB64);
      break;
    }

  // ── KOT received via signal server (offline-queued or real-time) ───────────
  } else if (msg.type === 'kot_received') {
    const _krFromHash = msg.fromHash;
    const _krSessionId = (typeof msg.sessionId === 'string' && msg.sessionId.length <= 64) ? msg.sessionId : null;
    if (!_krFromHash || !_krSessionId) return;
    // Find sender by hash
    const _krContacts = (typeof getContacts === 'function') ? getContacts() : [];
    for (const _krc of _krContacts) {
      const _krh = await _hashRawPubKey(_krc.pubKeyB64);
      if (_krh !== _krFromHash) continue;
      if (typeof addSessionKotRcvd === 'function') addSessionKotRcvd(_krSessionId);
      if (typeof _renderDashProfile === 'function') _renderDashProfile();
      if (typeof _renderDashTimer === 'function') _renderDashTimer();
      const _krName = _krc?.nickname || _krc?.journal?.displayName || 'A buddy';
      const _krTotal = typeof getSessionKotCount === 'function' ? getSessionKotCount(_krSessionId) : 1;
      if (typeof toast === 'function') toast(`${_krName} sent you a KOT!`);
      if (typeof pushNotification === 'function') {
        pushNotification('kot', `${_krName} sent you a KOT!`, _krTotal > 1 ? `${_krTotal} KOTs this session` : '', {
          tag: `session-kot-${_krSessionId}`,
          nav: { tab: 'dash' },
        });
      }
      break;
    }

  // ── Gallery photo note via signal server (offline-queued) ──────────────────
  } else if (msg.type === 'gallery_note_received') {
    const _gnFromHash  = msg.fromHash;
    if (!_gnFromHash) return;
    const _gnContacts = (typeof getContacts === 'function') ? getContacts() : [];
    let _gnSender = null;
    for (const _gnc of _gnContacts) {
      if (await _hashRawPubKey(_gnc.pubKeyB64) === _gnFromHash) { _gnSender = _gnc; break; }
    }
    if (!_gnSender) return;
    let _gnGalleryId, _gnItemId, _gnNote;
    if (typeof msg.payload === 'string') {
      try {
        const identity = await getIdentity();
        const _gnKey = await deriveSharedKey(identity.privKey, _gnSender.pubKeyB64);
        const d = await decryptWithKey(JSON.parse(msg.payload), _gnKey);
        _gnGalleryId = typeof d.galleryId === 'string' ? d.galleryId.slice(0, 64) : null;
        _gnItemId    = typeof d.itemId    === 'string' ? d.itemId.slice(0, 64)    : null;
        _gnNote      = typeof d.note      === 'string' ? d.note.trim().slice(0, 500) : '';
      } catch (e) { console.warn('[p2p] gallery note decrypt failed:', e); return; }
    } else {
      _gnGalleryId = typeof msg.galleryId === 'string' ? msg.galleryId.slice(0, 64) : null;
      _gnItemId    = typeof msg.itemId    === 'string' ? msg.itemId.slice(0, 64)    : null;
      _gnNote      = typeof msg.note      === 'string' ? msg.note.trim().slice(0, 500) : '';
    }
    if (!_gnGalleryId || !_gnItemId || !_gnNote) return;
    if (typeof p2pHandleGalleryPhotoNote === 'function') {
      p2pHandleGalleryPhotoNote(_gnSender.pubKeyB64, { galleryId: _gnGalleryId, itemId: _gnItemId, note: _gnNote });
    }

  // ── Journal comment (note) via signal server relay ──────────────────────────
  } else if (msg.type === 'note_received') {
    const _nrFromHash = msg.fromHash;
    if (!_nrFromHash) return;
    // Find the sender among our contacts (match by hash).
    const _nrContacts = (typeof getContacts === 'function') ? getContacts() : [];
    let _nrSender = null;
    for (const _nrc of _nrContacts) {
      if (await _hashRawPubKey(_nrc.pubKeyB64) === _nrFromHash) { _nrSender = _nrc; break; }
    }
    if (!_nrSender) return;
    // New encrypted form: decrypt the opaque payload with the pair key. Legacy
    // form: plaintext fields (kept so notes from not-yet-updated buddies still land).
    let _nrEntryId, _nrContent, _nrFromAvatar, _nrFromName, _nrTimestamp, _nrEditedAt;
    if (typeof msg.payload === 'string') {
      try {
        const identity = await getIdentity();
        const _nrKey = await deriveSharedKey(identity.privKey, _nrSender.pubKeyB64);
        const d = await decryptWithKey(JSON.parse(msg.payload), _nrKey);
        _nrEntryId    = (typeof d.entryId === 'string' && /^[A-Za-z0-9_-]{1,64}$/.test(d.entryId)) ? d.entryId : null;
        _nrContent    = typeof d.content === 'string' ? d.content.slice(0, 1000) : null;
        _nrFromAvatar = (typeof d.fromAvatar === 'string' && d.fromAvatar.length <= 8) ? d.fromAvatar : '❓';
        _nrFromName   = typeof d.fromName === 'string' ? d.fromName.slice(0, 64) : (_nrSender.nickname || 'Buddy');
        _nrTimestamp  = typeof d.timestamp === 'string' ? d.timestamp : new Date().toISOString();
        _nrEditedAt   = typeof d.editedAt === 'string' ? d.editedAt : null;
      } catch (e) { console.warn('[p2p] note decrypt failed:', e); return; }
    } else {
      _nrEntryId    = (typeof msg.entryId === 'string' && /^[A-Za-z0-9_-]{1,64}$/.test(msg.entryId)) ? msg.entryId : null;
      _nrContent    = typeof msg.content === 'string' ? msg.content.slice(0, 1000) : null;
      _nrFromAvatar = (typeof msg.fromAvatar === 'string' && msg.fromAvatar.length <= 8) ? msg.fromAvatar : '❓';
      _nrFromName   = typeof msg.fromName === 'string' ? msg.fromName.slice(0, 64) : 'Buddy';
      _nrTimestamp  = typeof msg.timestamp === 'string' ? msg.timestamp : new Date().toISOString();
      _nrEditedAt   = typeof msg.editedAt === 'string' ? msg.editedAt : null;
    }
    if (!_nrEntryId || !_nrContent) return;
    {
      const _nrc = _nrSender;
      addReceivedNote(_nrEntryId, _nrc.pubKeyB64, _nrFromAvatar, _nrFromName, _nrContent, _nrTimestamp, _nrEditedAt);
      if (typeof pushNotification === 'function') {
        pushNotification('note', `Comment from ${_nrFromName}`, _nrContent.slice(0, 80), {
          tag: `note-${_nrc.pubKeyB64}-${_nrEntryId}`,
          nav: { tab: 'journal', entryId: _nrEntryId },
          ttl: 7 * 86400 * 1000,
        });
      }
      if (typeof renderEntryViewer === 'function' && typeof _selEntryId !== 'undefined' && _selEntryId === _nrEntryId && !_selBuddyKey) {
        renderEntryViewer(_nrEntryId);
      } else if (typeof renderJournalPage === 'function' && typeof _selEntryId !== 'undefined' && !_selEntryId) {
        renderJournalPage();
      }
      if (typeof renderEntryList === 'function') renderEntryList();
    }

  // ── Per-entry KOT via signal server relay ──────────────────────────────────
  } else if (msg.type === 'entry_kot_received') {
    const _ekrFromHash = msg.fromHash;
    const _ekrEntryId  = (typeof msg.entryId === 'string' && msg.entryId.length <= 64) ? msg.entryId : null;
    if (!_ekrFromHash || !_ekrEntryId) return;
    const _ekrContacts = (typeof getContacts === 'function') ? getContacts() : [];
    for (const _ekrc of _ekrContacts) {
      const _ekrh = await _hashRawPubKey(_ekrc.pubKeyB64);
      if (_ekrh !== _ekrFromHash) continue;
      const _ekrFromName = _ekrc?.nickname || _ekrc?.journal?.displayName || 'Buddy';
      const _ekrFromAvatar = _ekrc?.journal?.avatar || null;
      addReceivedKot(_ekrEntryId, _ekrc.pubKeyB64, _ekrFromAvatar, _ekrFromName, new Date().toISOString());
      _batchKotNotify(_ekrFromName, _ekrc.pubKeyB64, _ekrEntryId);
      if (typeof renderEntryViewer === 'function' && typeof _selEntryId !== 'undefined' && _selEntryId === _ekrEntryId && !_selBuddyKey) {
        renderEntryViewer(_ekrEntryId);
      }
      break;
    }

  // ── Gallery KOT via signal server relay ────────────────────────────────────
  } else if (msg.type === 'gallery_kot_received') {
    const _gkrFromHash  = msg.fromHash;
    const _gkrGalleryId = typeof msg.galleryId === 'string' ? msg.galleryId.slice(0, 64) : null;
    const _gkrItemId    = typeof msg.itemId    === 'string' ? msg.itemId.slice(0, 64)    : null;
    if (!_gkrFromHash || !_gkrGalleryId || !_gkrItemId) return;
    // Validate we own this gallery
    const _gkrGalleries = typeof getGalleries === 'function' ? getGalleries() : [];
    const _gkrGal = _gkrGalleries.find(g => g.id === _gkrGalleryId);
    if (!_gkrGal || !_gkrGal.items.some(i => i.id === _gkrItemId)) return;
    const _gkrContacts = (typeof getContacts === 'function') ? getContacts() : [];
    for (const _gkrc of _gkrContacts) {
      const _gkrh = await _hashRawPubKey(_gkrc.pubKeyB64);
      if (_gkrh !== _gkrFromHash) continue;
      const senderSlice16 = _gkrc.pubKeyB64.slice(0, 16);
      const fromAvatar = _gkrc?.journal?.avatar || null;
      const fromName = _gkrc?.journal?.displayName || _gkrc?.nickname || null;
      if (typeof addGalleryKot === 'function') addGalleryKot(_gkrGalleryId, _gkrItemId, senderSlice16, fromAvatar, fromName);
      const buddyName = fromName || 'Buddy';
      const photoLabel = _gkrGal.items.find(i => i.id === _gkrItemId)?.title || 'a photo';
      if (typeof pushNotification === 'function') {
        pushNotification('kot', `${buddyName} KOT'd ${photoLabel} in "${_gkrGal.title}"`, '', {
          tag: `gal_kot_${_gkrGalleryId}_${_gkrItemId}_${senderSlice16}`, nav: { tab: 'gallery' }
        });
      }
      if (typeof renderGallery === 'function') renderGallery();
      break;
    }

  // ── Gallery manifest via signal server relay (encrypted) ───────────────────
  } else if (msg.type === 'gallery_manifest_received') {
    const _gmrFromHash = msg.fromHash;
    const _gmrPayload  = msg.payload;
    if (!_gmrFromHash || !_gmrPayload) return;
    const _gmrContacts = (typeof getContacts === 'function') ? getContacts() : [];
    for (const _gmrc of _gmrContacts) {
      const _gmrh = await _hashRawPubKey(_gmrc.pubKeyB64);
      if (_gmrh !== _gmrFromHash) continue;
      try {
        const identity = await getIdentity();
        if (!identity?.privKey) break;
        const key = await deriveSharedKey(identity.privKey, _gmrc.pubKeyB64);
        const decrypted = await decryptWithKey(JSON.parse(_gmrPayload), key);
        if (decrypted && typeof p2pHandleGalleryManifest === 'function') {
          p2pHandleGalleryManifest(_gmrc.pubKeyB64, decrypted);
          // If sender is currently connected, drain photo queue immediately
          if (typeof _galDrainQueue === 'function') _galDrainQueue(_gmrc.pubKeyB64);
        }
      } catch(e) { console.warn('[p2p] gallery manifest relay decrypt failed:', e); }
      break;
    }

  // ── Gallery photo via signal server relay (encrypted) ──────────────────────
  } else if (msg.type === 'gallery_photo_received') {
    const _gprFromHash  = msg.fromHash;
    const _gprGalleryId = typeof msg.galleryId === 'string' ? msg.galleryId.slice(0, 64) : null;
    const _gprItemId    = typeof msg.itemId    === 'string' ? msg.itemId.slice(0, 64)    : null;
    const _gprPayload   = msg.payload;
    if (!_gprFromHash || !_gprGalleryId || !_gprItemId || !_gprPayload) return;
    const _gprContacts = (typeof getContacts === 'function') ? getContacts() : [];
    for (const _gprc of _gprContacts) {
      const _gprh = await _hashRawPubKey(_gprc.pubKeyB64);
      if (_gprh !== _gprFromHash) continue;
      try {
        const identity = await getIdentity();
        if (!identity?.privKey) break;
        const key = await deriveSharedKey(identity.privKey, _gprc.pubKeyB64);
        const parsed = JSON.parse(_gprPayload);
        // Decrypt: iv is small (safe for bytesFromB64), ciphertext is large (use _largeB64Decode)
        const iv = bytesFromB64(parsed.iv);
        const ct = _largeB64Decode(fromUrlSafeB64(parsed.data));
        const plain = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, key, ct);
        const decrypted = JSON.parse(new TextDecoder().decode(plain));
        if (!decrypted.data) break;
        const imgBytes = _largeB64Decode(decrypted.data);
        const blob = new Blob([imgBytes], { type: decrypted.mimeType || 'image/jpeg' });

        // Chat photo -- store under chatimg_ key and update chat history
        if (_gprGalleryId === '_chat') {
          const chatImgKey = 'chatimg_' + _gprItemId;
          if (typeof imgCache !== 'undefined') {
            if (imgCache[chatImgKey]) URL.revokeObjectURL(imgCache[chatImgKey]);
            imgCache[chatImgKey] = URL.createObjectURL(blob);
          }
          await idbPut(chatImgKey, blob);
          // Update any chat message that references this key
          const hist = typeof getChatHistory === 'function' ? getChatHistory(_gprc.pubKeyB64) : [];
          const mi = hist.findIndex(m => m.imgKey === chatImgKey);
          if (mi >= 0 && typeof saveChatHistory === 'function') saveChatHistory(_gprc.pubKeyB64, hist);
          if (typeof refreshChatPopup === 'function') refreshChatPopup(_gprc.pubKeyB64);
          break;
        }

        const senderSlice16 = _gprc.pubKeyB64.slice(0, 16);
        const galIdbKey = `gal_${senderSlice16}_${_gprGalleryId}_${_gprItemId}`;
        if (typeof imgCache !== 'undefined') {
          if (imgCache[galIdbKey]) URL.revokeObjectURL(imgCache[galIdbKey]);
          imgCache[galIdbKey] = URL.createObjectURL(blob);
        }
        await idbPut(galIdbKey, blob);
        if (typeof p2pGalleryImageReceived === 'function') {
          p2pGalleryImageReceived(_gprc.pubKeyB64, _gprGalleryId, _gprItemId, galIdbKey);
        }
        if (typeof rerenderPhotoCanvases === 'function') rerenderPhotoCanvases(galIdbKey);
      } catch(e) { console.warn('[p2p] gallery photo relay decrypt failed:', e); }
      break;
    }

  // ── Journal update via signal server relay (store-and-forward) ─────────────
  } else if (msg.type === 'journal_update') {
    const _juFromHash = msg.fromHash;
    const _juPayload  = msg.payload;
    if (!_juFromHash || !_juPayload) return;
    // Find sender by hash
    const _juContacts = (typeof getContacts === 'function') ? getContacts() : [];
    let _juSenderPub = null, _juSenderIdx = -1;
    for (let _jui = 0; _jui < _juContacts.length; _jui++) {
      const _juh = await _hashRawPubKey(_juContacts[_jui].pubKeyB64);
      if (_juh === _juFromHash) { _juSenderPub = _juContacts[_jui].pubKeyB64; _juSenderIdx = _jui; break; }
    }
    if (!_juSenderPub || _juSenderIdx < 0) return; // unknown sender
    try {
      const identity = await getIdentity();
      if (!identity?.privKey) return;
      const key = await deriveSharedKey(identity.privKey, _juSenderPub);
      const decrypted = await decryptWithKey(JSON.parse(_juPayload), key);
      if (!decrypted || !decrypted.entry || !decrypted.entry.id) return;

      const contacts = getContacts(); // re-read in case changed
      const ci = contacts.findIndex(c => c.pubKeyB64 === _juSenderPub);
      if (ci < 0) return;

      // Merge entry into contact's journal (LWW by updatedAt)
      if (!contacts[ci].journal) contacts[ci].journal = { entries: [], displayName: '', avatar: null };
      const safeEntry = _sanitizeEntry(decrypted.entry);
      if (!safeEntry) { console.warn('[p2p] journal relay: entry failed sanitization'); return; }
      safeEntry._viaRelay = true; // mark for UI hint
      const entryMap = new Map(contacts[ci].journal.entries.map(e => [e.id, e]));
      const existing = entryMap.get(safeEntry.id);
      const incomingTs = safeEntry.updatedAt || safeEntry.date || 0;
      if (!existing || incomingTs > (existing.updatedAt || existing.date || 0)) {
        entryMap.set(safeEntry.id, safeEntry);
        contacts[ci].journal.entries = [...entryMap.values()];
      }
      // Update metadata
      if (decrypted.displayName) {
        contacts[ci].journal.displayName = decrypted.displayName;
        contacts[ci].nickname = decrypted.displayName;
      }
      if (decrypted.avatar != null) contacts[ci].journal.avatar = decrypted.avatar;
      if (typeof decrypted.trusted === 'boolean') contacts[ci].senderTrusts = decrypted.trusted;
      if (typeof decrypted.allowView === 'string' && _SCOPE_ORDER.includes(decrypted.allowView))
        contacts[ci].senderViewScope = decrypted.allowView;
      if (typeof decrypted.allowCache === 'string' && _SCOPE_ORDER.includes(decrypted.allowCache)) {
        contacts[ci].senderCacheScope = decrypted.allowCache;
        contacts[ci].senderAllowsCache = decrypted.allowCache !== 'none';
      }

      saveContacts(contacts);
      if (typeof renderContactsList === 'function') renderContactsList();
      if (typeof refreshPeerJournalPanel === 'function') refreshPeerJournalPanel(_juSenderPub);
      if (typeof _renderDashProfile === 'function') _renderDashProfile();
      if (typeof buddyRipple === 'function') buddyRipple();
      console.log(`[p2p] journal relay from ${_juSenderPub.slice(0,8)}… -- entry ${decrypted.entry.id} merged`);
    } catch(e) {
      console.warn('[p2p] journal relay decrypt failed:', e);
    }

  // ── Journal photo received via signal server (48h persisted) ────────────────
  } else if (msg.type === 'journal_photo_received') {
    const _jpFromHash = msg.fromHash;
    const _jpEntryId  = msg.entryId;
    const _jpField    = msg.field;
    const _jpPayload  = msg.payload;
    if (!_jpFromHash || !_jpEntryId || !_jpField || !_jpPayload) return;
    if (!/^(front|left|right)$/.test(_jpField)) return;
    // Find sender by hash
    const _jpContacts = (typeof getContacts === 'function') ? getContacts() : [];
    let _jpc = null;
    for (const _jpcc of _jpContacts) {
      const _jph = await _hashRawPubKey(_jpcc.pubKeyB64);
      if (_jph === _jpFromHash) { _jpc = _jpcc; break; }
    }
    if (!_jpc || _jpc.removedAt || _jpc.forgottenAt || _jpc.pendingRemoval) return;
    try {
      const identity = await getIdentity();
      if (!identity?.privKey) return;
      const dKey = await deriveSharedKey(identity.privKey, _jpc.pubKeyB64);
      const parsed = JSON.parse(_jpPayload);
      const iv = bytesFromB64(parsed.iv);
      const ct = _largeB64Decode(fromUrlSafeB64(parsed.data));
      const plain = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, dKey, ct);
      const decrypted = JSON.parse(new TextDecoder().decode(plain));
      if (!decrypted.data) return;
      const imgBytes = _largeB64Decode(decrypted.data);
      const blob = new Blob([imgBytes], { type: decrypted.mimeType || 'image/jpeg' });
      // Store under the standard buddy cached photo key: c_<senderSlice16>_<entryId>_<field>
      const idbKey = `c_${_jpc.pubKeyB64.slice(0, 16)}_${_jpEntryId}_${_jpField}`;
      // Receiver-side caching preferences honored: if noCacheIncoming, RAM only
      const _bPermsRecv = (typeof getBuddyPermissions === 'function') ? getBuddyPermissions() : {};
      if (typeof imgCache !== 'undefined') {
        if (imgCache[idbKey]) URL.revokeObjectURL(imgCache[idbKey]);
        imgCache[idbKey] = URL.createObjectURL(blob);
      }
      if (!_bPermsRecv.noCacheIncoming) await idbPut(idbKey, blob);
      if (typeof rerenderPhotoCanvases === 'function') rerenderPhotoCanvases(idbKey);
      if (typeof refreshContactImages === 'function') refreshContactImages(_jpc.pubKeyB64);
    } catch(e) { console.warn('[p2p] journal_photo_received decrypt/store failed:', e); }

  // ── Snapshot received via signal server (24h presence cache) ─────────────────
  } else if (msg.type === 'snapshot_received') {
    const _snFromHash = msg.fromHash;
    const _snPayload  = msg.payload;
    if (!_snFromHash || !_snPayload) return;
    const _snContacts = (typeof getContacts === 'function') ? getContacts() : [];
    let _snSenderPub = null, _snSenderIdx = -1;
    for (let _sni = 0; _sni < _snContacts.length; _sni++) {
      const _snh = await _hashRawPubKey(_snContacts[_sni].pubKeyB64);
      if (_snh === _snFromHash) { _snSenderPub = _snContacts[_sni].pubKeyB64; _snSenderIdx = _sni; break; }
    }
    if (!_snSenderPub || _snSenderIdx < 0) return;
    try {
      const identity = await getIdentity();
      if (!identity?.privKey) return;
      const key = await deriveSharedKey(identity.privKey, _snSenderPub);
      const snap = await decryptWithKey(JSON.parse(_snPayload), key);
      if (!snap || snap.v !== 1) return;

      const contacts = getContacts();
      const ci = contacts.findIndex(c => c.pubKeyB64 === _snSenderPub);
      if (ci < 0) return;

      // Merge snapshot into contact journal structure (same as journal_update)
      if (!contacts[ci].journal) contacts[ci].journal = { entries: [], displayName: '', avatar: null };

      if (snap.latestEntry && snap.latestEntry.id) {
        const safeEntry = _sanitizeEntry(snap.latestEntry);
        if (safeEntry) {
          safeEntry._viaSnapshot = true;
          const entryMap = new Map(contacts[ci].journal.entries.map(e => [e.id, e]));
          const existing = entryMap.get(safeEntry.id);
          const incomingTs = safeEntry.updatedAt || safeEntry.date || 0;
          if (!existing || incomingTs > (existing.updatedAt || existing.date || 0)) {
            entryMap.set(safeEntry.id, safeEntry);
            contacts[ci].journal.entries = [...entryMap.values()];
          }
        }
      }
      if (snap.displayName) {
        contacts[ci].journal.displayName = snap.displayName;
        contacts[ci].nickname = snap.displayName;
      }
      if (snap.avatar != null) contacts[ci].journal.avatar = snap.avatar;
      if (snap.profile) contacts[ci].journal.profile = snap.profile;
      if (typeof snap.trusted === 'boolean') contacts[ci].senderTrusts = snap.trusted;
      if (typeof snap.allowView === 'string' && _SCOPE_ORDER.includes(snap.allowView))
        contacts[ci].senderViewScope = snap.allowView;
      if (typeof snap.allowCache === 'string' && _SCOPE_ORDER.includes(snap.allowCache)) {
        contacts[ci].senderCacheScope = snap.allowCache;
        contacts[ci].senderAllowsCache = snap.allowCache !== 'none';
      }
      contacts[ci].lastSeen = new Date(snap.ts).toISOString();

      // Apply timer status (same logic as buddy_timer_status handler)
      if (snap.timerStatus && (snap.timerStatus.status === 'tugging' || snap.timerStatus.status === 'retaining')) {
        const _snSa = Number(snap.timerStatus.startedAt);
        if (_snSa && isFinite(_snSa)) {
          _buddyTimerStates.set(_snSenderPub, {
            type: snap.timerStatus.status, startedAt: _snSa,
            id: null, device: snap.timerStatus.device || null,
            todayTugMs: 0, todayRetMs: 0
          });
        }
      } else if (snap.timerStatus === null) {
        // Snapshot says no active timer -- clear if we don't have a richer P2P state cached
        const _snExisting = _buddyTimerStates.get(_snSenderPub);
        if (!_snExisting || (!_snExisting.todayTugMs && !_snExisting.todayRetMs)) {
          _buddyTimerStates.delete(_snSenderPub);
        }
      }
      if (typeof _updateBuddyTimerBadge === 'function') _updateBuddyTimerBadge(_snSenderPub);

      saveContacts(contacts);
      if (typeof renderContactsList === 'function') renderContactsList();
      if (typeof refreshPeerJournalPanel === 'function') refreshPeerJournalPanel(_snSenderPub);
      if (typeof _renderDashProfile === 'function') _renderDashProfile();
      if (typeof buddyRipple === 'function') buddyRipple();
      console.log(`[p2p] snapshot from ${_snSenderPub.slice(0,8)}… merged`);
    } catch(e) {
      console.warn('[p2p] snapshot decrypt failed:', e);
    }

  // ── Snapshot photo received via signal server ───────────────────────────────
  } else if (msg.type === 'snapshot_photo_received') {
    const _sphFromHash = msg.fromHash;
    const _sphPayload  = msg.payload;
    if (!_sphFromHash || !_sphPayload) return;
    const _sphContacts = (typeof getContacts === 'function') ? getContacts() : [];
    let _sphSenderPub = null;
    for (let _sphi = 0; _sphi < _sphContacts.length; _sphi++) {
      const _sphh = await _hashRawPubKey(_sphContacts[_sphi].pubKeyB64);
      if (_sphh === _sphFromHash) { _sphSenderPub = _sphContacts[_sphi].pubKeyB64; break; }
    }
    if (!_sphSenderPub) return;
    try {
      const identity = await getIdentity();
      if (!identity?.privKey) return;
      const key = await deriveSharedKey(identity.privKey, _sphSenderPub);
      // Decrypt using raw crypto.subtle (same pattern as gallery photo relay receive)
      const envelope = JSON.parse(_sphPayload);
      const ivB64  = (envelope.iv || '').replace(/-/g, '+').replace(/_/g, '/');
      const ctB64  = (envelope.data || '').replace(/-/g, '+').replace(/_/g, '/');
      const iv     = Uint8Array.from(atob(ivB64), c => c.charCodeAt(0));
      const ct     = _largeB64Decode(ctB64);
      const plain  = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, key, ct);
      const dec    = JSON.parse(new TextDecoder().decode(plain));
      if (!dec.data || !dec.entryId) return;
      // Decode base64 photo → blob
      const binary = atob(dec.data);
      const bytes  = new Uint8Array(binary.length);
      for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
      const blob   = new Blob([bytes], { type: dec.mimeType || 'image/jpeg' });
      // Store using standard buddy photo key format
      const senderSlice16 = _sphSenderPub.slice(0, 16);
      const idbKey = `c_${senderSlice16}_${dec.entryId}_front`;
      if (typeof imgCache !== 'undefined') {
        if (imgCache[idbKey]) URL.revokeObjectURL(imgCache[idbKey]);
        imgCache[idbKey] = URL.createObjectURL(blob);
      }
      // Respect cache preferences (same rules as P2P photo receive)
      const _sphContact = _sphContacts.find(c => c.pubKeyB64 === _sphSenderPub);
      const _sphPerms = typeof getBuddyPermissions === 'function' ? getBuddyPermissions() : {};
      const _sphNoCacheIncoming = !!_sphPerms.noCacheIncoming;
      const _sphMutualTrust = !!(_sphContact?.trusted && _sphContact?.senderTrusts);
      const _sphSenderAllowsCache = !!_sphContact?.senderAllowsCache;
      if (!_sphNoCacheIncoming && (_sphMutualTrust || _sphSenderAllowsCache)) {
        await idbPut(idbKey, blob);
      }
      // Otherwise RAM-only -- visible this session but not persisted
      if (typeof rerenderPhotoCanvases === 'function') rerenderPhotoCanvases(idbKey);
      if (typeof renderContactsList === 'function') renderContactsList();
      if (typeof _renderDashProfile === 'function') _renderDashProfile();
      console.log(`[p2p] snapshot photo from ${_sphSenderPub.slice(0,8)}… stored as ${idbKey}`);
    } catch(e) {
      console.warn('[p2p] snapshot photo decrypt failed:', e);
    }
  }
}
